JWT Security Best Practices

SecurityBackendAPIArchitecture
Share on LinkedIn

JWT libraries make it easy to decode a token and read the payload. They don't stop you from skipping signature verification, accepting the none algorithm, or putting a 30-day expiry on an access token. I've audited JWT implementations where the "fix" for token theft was "make the token longer" — a 4,096-bit secret doesn't help if you never verify it, if the algorithm is none, or if a stolen token works for 30 days. JWT security is about how you issue, validate, and rotate tokens — not which library you pick.

Token architecture

Login → Auth server issues:
         ├── Access token (JWT, 15 min, RS256)
         └── Refresh token (opaque, 7 days, stored server-side)

API request → Authorization: Bearer <access_token>
             → Verify signature, exp, iss, aud
             → Extract claims → authorize

Access expired → POST /token/refresh { refresh_token }
              → New access token + rotated refresh token
              → Old refresh token invalidated

Two tokens, two purposes. Access tokens are self-contained and short-lived. Refresh tokens are opaque, server-tracked, and revocable.

Algorithm selection

# Auth server — sign with RS256
import jwt

private_key = load_key("auth-server-private.pem")
access_token = jwt.encode(
    {
        "sub": user.id,
        "iss": "https://auth.example.com",
        "aud": "https://api.example.com",
        "exp": datetime.utcnow() + timedelta(minutes=15),
        "iat": datetime.utcnow(),
        "scope": "read:orders write:orders",
    },
    private_key,
    algorithm="RS256",
    headers={"kid": "key-2025-09"},  # key ID for rotation
)
# API server — verify with public key
public_key = load_key("auth-server-public.pem")
payload = jwt.decode(
    token,
    public_key,
    algorithms=["RS256"],  # EXPLICIT whitelist — never ["HS256", "RS256", "none"]
    audience="https://api.example.com",
    issuer="https://auth.example.com",
)

The algorithms parameter is critical. Without it, an attacker can switch the algorithm to HS256 and sign with the public key (which is often available):

# VULNERABLE — accepts any algorithm
payload = jwt.decode(token, public_key)  # DON'T DO THIS

# SAFE — explicit algorithm whitelist
payload = jwt.decode(token, public_key, algorithms=["RS256"])

Refresh token rotation

Every refresh token use issues a new pair and invalidates the old refresh token:

def refresh_tokens(refresh_token: str):
    stored = db.get_refresh_token(refresh_token)
    if not stored or stored.revoked or stored.expires_at < now():
        # Token reuse detected — revoke entire token family
        if stored and stored.revoked:
            db.revoke_token_family(stored.family_id)
            alert_security("Refresh token reuse detected", family=stored.family_id)
        raise InvalidTokenError()

    db.revoke(stored.id)

    new_refresh = db.create_refresh_token(
        user_id=stored.user_id,
        family_id=stored.family_id,
    )
    new_access = create_access_token(stored.user_id)

    return {"access_token": new_access, "refresh_token": new_refresh.token}

If an attacker steals a refresh token and the legitimate user also uses it, the second use detects reuse and revokes the entire family — both attacker and user must re-authenticate.

Claims to validate

Claim Check Failure action
sig Signature valid with expected key 401
exp Not expired 401
iss Matches your auth server 401
aud Matches your API identifier 401
nbf Not before time has passed 401
alg (header) Matches whitelist 401
sub User exists and is active 401 or 403
def validate_token(token: str) -> dict:
    header = jwt.get_unverified_header(token)

    if header.get("alg") not in ALLOWED_ALGORITHMS:
        raise InvalidAlgorithmError(header["alg"])

    if header.get("alg") == "none":
        raise InvalidAlgorithmError("none")

    kid = header.get("kid")
    public_key = key_store.get_public_key(kid)

    payload = jwt.decode(
        token, public_key,
        algorithms=ALLOWED_ALGORITHMS,
        audience=EXPECTED_AUDIENCE,
        issuer=EXPECTED_ISSUER,
        options={"require": ["exp", "iss", "aud", "sub"]},
    )

    user = db.get_user(payload["sub"])
    if not user or user.status != "active":
        raise UserInactiveError()

    return payload

What NOT to put in JWTs

Don't store Why
Passwords Obvious
PII (email, phone, address) Tokens are base64, not encrypted — readable by anyone
Permissions that change frequently Can't revoke until expiry — use short TTL or server-side checks
Large objects Tokens travel on every request — keep under 1 KB
Session state Defeats the purpose of stateless tokens

Store sub (user ID) and scope (permissions). Look up user details server-side.

Key rotation

Rotate signing keys without downtime:

# JWKS endpoint exposes all active public keys
@app.get("/.well-known/jwks.json")
def jwks():
    return {"keys": [key.to_jwk() for key in key_store.active_keys()]}

# Sign with latest key
access_token = jwt.encode(payload, key_store.latest_private_key, algorithm="RS256",
                          headers={"kid": key_store.latest_key_id})

# Verify tries all active public keys
def verify(token):
    kid = jwt.get_unverified_header(token)["kid"]
    public_key = key_store.get_public_key(kid)
    return jwt.decode(token, public_key, algorithms=["RS256"], ...)

Keep old keys active for 2x the max access token TTL (30 min if tokens are 15 min). Then retire.

Common vulnerabilities checklist

Common production mistakes

Teams get jwt security best practices wrong in predictable ways:

Production implementations of jwt security best practices fail when staging mirrors production topology poorly, rollback is untested, and on-call runbooks describe the happy path only.

Resources

Frequently asked questions

What JWT algorithm should I use?

RS256 (RSA + SHA-256) or ES256 (ECDSA + P-256) for asymmetric signing — the verifier uses a public key, so the signing key never leaves the auth server. Never use HS256 with a shared secret in microservices (secret must be shared with every verifier). Never allow 'none' algorithm. Explicitly whitelist allowed algorithms in your JWT library.

How long should JWT access tokens live?

15 minutes or less for access tokens. Short lifetimes limit the damage from a stolen token. Pair with refresh tokens (7-30 days, stored securely, rotated on each use) for session continuity. For high-security applications, use 5-minute access tokens with silent refresh.

What JWT claims must I validate?

Validate: signature (always), exp (not expired), iss (expected issuer), aud (intended audience), nbf (not before, if present). Check that alg in the header matches your expected algorithm (prevents algorithm confusion attacks). Never trust claims without signature verification.

Hiring a senior Android / Flutter engineer?

I architect and ship production mobile software — Kotlin, Jetpack Compose, Flutter — for robotics, EV infrastructure, fintech, and real-time systems. Open to remote roles in Europe and the US.

Get in touch →