# JWT and API Security — How Tokens Work, Common JWT Mistakes, Rate Limits and Input Validation

Source: https://skills101.in/learn/web-security/jwt-api-security — Skills101 free tutorial (Web Security (OWASP Top 10)). Updated 2026-11-09.

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

```text
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](https://skills101.in/learn-img/web-security/jwt-parts.svg)

- **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:

```python
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))
```

```text
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:

```python
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}))
```

```text
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 quiz and free certificate: https://skills101.in/learn/web-security/jwt-api-security