Web Security (OWASP Top 10) · Lesson 8 of 9 · Updated ·
JWT and API Security — How Tokens Work, Common JWT Mistakes, Rate Limits and Input Validation
Mobile apps and single-page apps talk to APIs, and APIs usually authenticate with tokens. The most common format is the JWT (JSON Web Token, pronounced "jot"). It's simple — and easy to get wrong, which leads to A07 Authentication Failures and A08 Software or Data Integrity Failures.
1. What a JWT looks like
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI0MiIsInJvbGUiOiJzdHVkZW50In0.Sig...
header (Base64URL) . payload (Base64URL) . signature
- Header:
{"alg": "HS256", "typ": "JWT"} - Payload (claims):
{"sub": "42", "role": "student", "exp": 1767225600} - Signature:
HMAC-SHA256(header + "." + payload, secret)
Base64 is not encryption. Anyone can decode the payload. A JWT proves the data wasn't changed (integrity) — it does not hide it. Never put passwords, phone numbers or other secrets in a JWT payload.
2. Build and verify one by hand
Using only Python's standard library, to see every step:
import base64, hashlib, hmac, json
SECRET = b"a-long-random-server-secret-from-env"
def b64url(data: bytes) -> str:
return base64.urlsafe_b64encode(data).rstrip(b"=").decode()
def b64url_decode(s: str) -> bytes:
return base64.urlsafe_b64decode(s + "=" * (-len(s) % 4))
def sign(payload):
header = b64url(json.dumps({"alg": "HS256", "typ": "JWT"}, separators=(",", ":")).encode())
body = b64url(json.dumps(payload, separators=(",", ":")).encode())
sig = b64url(hmac.new(SECRET, f"{header}.{body}".encode(), hashlib.sha256).digest())
return f"{header}.{body}.{sig}"
def verify(token, now):
try:
header, body, sig = token.split(".")
except ValueError:
return "invalid: format"
if json.loads(b64url_decode(header)).get("alg") != "HS256":
return "invalid: algorithm not allowed"
expected = b64url(hmac.new(SECRET, f"{header}.{body}".encode(), hashlib.sha256).digest())
if not hmac.compare_digest(sig, expected):
return "invalid: signature"
claims = json.loads(b64url_decode(body))
if claims["exp"] < now:
return "invalid: expired"
return claims
NOW = 1_800_000_000
token = sign({"sub": "42", "role": "student", "exp": NOW + 900})
print("decoded payload (anyone can read it):", b64url_decode(token.split(".")[1]).decode())
print("valid:", verify(token, NOW))
# attacker edits the payload to become admin, keeps the old signature
h, b, s = token.split(".")
forged = h + "." + b64url(json.dumps({"sub": "42", "role": "admin", "exp": NOW + 900}, separators=(",", ":")).encode()) + "." + s
print("forged:", verify(forged, NOW))
print("later:", verify(token, NOW + 3600))
decoded payload (anyone can read it): {"sub":"42","role":"student","exp":1800000900}
valid: {'sub': '42', 'role': 'student', 'exp': 1800000900}
forged: invalid: signature
later: invalid: expired
Changing role to admin breaks the signature, because the attacker doesn't know the secret.
3. The classic JWT mistakes
| Mistake | What goes wrong | Fix |
|---|---|---|
Accepting "alg": "none" | unsigned tokens accepted as valid | allow-list the algorithm(s) when verifying (the demo rejects anything but HS256) |
Weak secret (secret, 123456) | attackers crack it offline from one token and then sign their own | 256-bit random secret from an env variable; or RS256/ES256 key pairs |
| Algorithm confusion | RS256 public key used as an HS256 secret | fix the algorithm on the server; never trust the header blindly |
| No expiry / very long expiry | a stolen token works for months | short access tokens (5–15 min) + refresh tokens |
decode() instead of verify() | library just reads the payload | always use the verify function with algorithms specified |
Token in localStorage | any XSS can steal it | prefer HttpOnly cookies for browser apps |
| Can't log out | JWTs stay valid until expiry | short expiry, refresh-token rotation, a deny-list for revoked tokens |
In real code use a maintained library (PyJWT, jsonwebtoken, jjwt, Spring Security) and always pass the allowed algorithms, e.g. jwt.decode(token, key, algorithms=["HS256"]).
4. Sessions or JWT?
| Server sessions (cookie with random ID) | JWT | |
|---|---|---|
| Logout / revoke | instant (delete the session) | hard (wait for expiry or keep a deny-list) |
| Server storage | needs a session store | stateless |
| Best for | normal websites | APIs across services, mobile apps, short-lived service tokens |
For a typical website, server sessions are simpler and safer. JWTs shine when many services must verify identity without a shared database.
5. API security checklist
Authentication and authorization on every endpoint — the API is the real front door (OWASP also publishes a separate API Security Top 10, led by Broken Object Level Authorization — the API version of IDOR).
Validate input with a schema — types, lengths, ranges, allowed values:
def validate_review(body):
errors = []
if not isinstance(body.get("course_id"), int) or body["course_id"] <= 0:
errors.append("course_id must be a positive integer")
rating = body.get("rating")
if not isinstance(rating, int) or not 1 <= rating <= 5:
errors.append("rating must be 1-5")
text = body.get("text", "")
if not isinstance(text, str) or len(text) > 2000:
errors.append("text must be at most 2000 characters")
unknown = set(body) - {"course_id", "rating", "text"}
if unknown:
errors.append(f"unknown fields: {sorted(unknown)}")
return errors or "ok"
print(validate_review({"course_id": 7, "rating": 5, "text": "Loved it"}))
print(validate_review({"course_id": "7 OR 1=1", "rating": 50, "is_admin": True}))
ok
['course_id must be a positive integer', 'rating must be 1-5', "unknown fields: ['is_admin']"]
Rate limit per user, per IP and per expensive endpoint (login, OTP, search, code execution); return 429 Too Many Requests.
Limit what you return — send only the fields the client needs (no password hashes, internal flags or other users' emails in JSON).
Safe errors — {"error": "Invalid request"} to the client; the stack trace goes to your logs only.
Pagination limits — ?limit=1000000 shouldn't dump your table.
Common mistakes
- Treating the JWT payload as secret.
- Verifying the signature but not
exp,iss(issuer) oraud(audience). - Trusting
rolefrom the token for highly sensitive actions without re-checking the database when it matters (e.g. after a user is demoted). - Documented-but-forgotten endpoints (
/api/v1/still live after/api/v2/added checks).
Quick recap
- JWT = header.payload.signature; signed, not encrypted.
- Verify with a fixed algorithm list, strong secret/keys, check
exp(andiss/aud); keep tokens short-lived. - For websites, server sessions in
HttpOnlycookies are often simpler and safer. - APIs: auth on every endpoint, schema validation, rate limits, minimal responses, safe errors.