Skills101

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

A JWT has three parts separated by dots: a header saying which algorithm signed it, a payload with claims such as user id, role and expiry, and a signature made with the server's secret key

  • 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

MistakeWhat goes wrongFix
Accepting "alg": "none"unsigned tokens accepted as validallow-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 own256-bit random secret from an env variable; or RS256/ES256 key pairs
Algorithm confusionRS256 public key used as an HS256 secretfix the algorithm on the server; never trust the header blindly
No expiry / very long expirya stolen token works for monthsshort access tokens (5–15 min) + refresh tokens
decode() instead of verify()library just reads the payloadalways use the verify function with algorithms specified
Token in localStorageany XSS can steal itprefer HttpOnly cookies for browser apps
Can't log outJWTs stay valid until expiryshort 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 / revokeinstant (delete the session)hard (wait for expiry or keep a deny-list)
Server storageneeds a session storestateless
Best fornormal websitesAPIs 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) or aud (audience).
  • Trusting role from 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 (and iss/aud); keep tokens short-lived.
  • For websites, server sessions in HttpOnly cookies are often simpler and safer.
  • APIs: auth on every endpoint, schema validation, rate limits, minimal responses, safe errors.

Practice what you learned

📝 Quiz: JWT and API Security5 questions · instant answers & explanations · +10 XP · counts towards your free certificateTake the quiz →