API Security: JWT Best Practices and Common Pitfalls
JSON Web Tokens are everywhere. They power authentication in single-page apps, mobile clients, and service-to-service communication. But JWTs are also one of the most commonly misconfigured security primitives. A single mistake — accepting unsigned tokens, using weak secrets, or skipping expiry validation — can turn your authentication layer into an open door.
JWT Structure#
A JWT consists of three Base64url-encoded parts separated by dots:
Header.Payload.Signature
eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImtleS0yMDI2LTAzIn0
.
eyJzdWIiOiJ1c2VyXzEyMyIsImlhdCI6MTcxMTY4MDAwMCwiZXhwIjoxNzExNjgz
NjAwLCJpc3MiOiJodHRwczovL2F1dGguZXhhbXBsZS5jb20iLCJhdWQiOiJhcGku
ZXhhbXBsZS5jb20iLCJyb2xlcyI6WyJ1c2VyIl19
.
(signature bytes)
Header — Declares the signing algorithm and key identifier:
{
"alg": "RS256",
"typ": "JWT",
"kid": "key-2026-03"
}
Payload — Contains claims about the subject:
{
"sub": "user_123",
"iat": 1711680000,
"exp": 1711683600,
"iss": "https://auth.example.com",
"aud": "api.example.com",
"roles": ["user"]
}
Signature — Cryptographic proof that the header and payload have not been tampered with.
Signing Algorithms: RS256 vs HS256#
This is one of the most important decisions in your JWT architecture:
HS256 (HMAC-SHA256) — Symmetric#
A single shared secret signs and verifies the token. The same key is used on both sides.
Auth Server ──(shared secret)──▶ API Server
│ │
│ sign(header.payload, secret) │ verify(token, secret)
│ │
When to use: Single-service architectures where the issuer and verifier are the same process or share a secure secret store.
Risks: Every service that needs to verify tokens must have the secret. If any one is compromised, an attacker can forge tokens.
RS256 (RSA-SHA256) — Asymmetric#
The auth server signs with a private key. API servers verify with the corresponding public key.
Auth Server ──(private key: signs)
│
▼
Public Key ──▶ API Server 1
──▶ API Server 2
──▶ API Server 3
──▶ Third-party Service
When to use: Microservice architectures, multi-tenant systems, and any scenario where multiple services or third parties verify tokens.
Advantages: Public keys can be freely distributed. Compromising a verifier does not allow token forgery.
Algorithm Comparison#
| Property | HS256 | RS256 | ES256 |
|---|---|---|---|
| Type | Symmetric | Asymmetric (RSA) | Asymmetric (ECDSA) |
| Key size | 256-bit secret | 2048-bit key pair | 256-bit key pair |
| Sign speed | Fast | Slow | Fast |
| Verify speed | Fast | Fast | Moderate |
| Token size | ~300 bytes | ~500 bytes | ~350 bytes |
| Best for | Single service | Distributed systems | Mobile, IoT |
Recommendation: Default to RS256 or ES256 for new systems. Use HS256 only when you have a single trusted service.
Token Expiry Strategy#
Short-lived tokens limit the blast radius of a compromise:
Access Token: 15 minutes (used for API calls)
Refresh Token: 7 days (used to obtain new access tokens)
ID Token: 1 hour (used for client-side user info)
Why short access tokens?
- A stolen access token is useful for at most 15 minutes.
- Revocation is not needed for most cases — just wait for expiry.
- Forces clients to regularly check in with the auth server via refresh.
// Middleware: Validate expiry
function validateToken(req, res, next) {
const token = extractBearerToken(req);
if (!token) return res.status(401).json({ error: 'Missing token' });
try {
const payload = jwt.verify(token, publicKey, {
algorithms: ['RS256'], // Explicitly whitelist algorithms
issuer: 'https://auth.example.com',
audience: 'api.example.com',
clockTolerance: 30, // 30-second clock skew allowance
});
req.user = payload;
next();
} catch (err) {
if (err.name === 'TokenExpiredError') {
return res.status(401).json({ error: 'Token expired' });
}
return res.status(401).json({ error: 'Invalid token' });
}
}
Refresh Token Rotation#
Refresh tokens are long-lived and high-value targets. Rotation ensures that each refresh token can only be used once:
Client Auth Server Database
│ │ │
│── POST /token/refresh ────▶│ │
│ { refresh_token: RT1 } │── Validate RT1 ────────▶│
│ │◀── RT1 valid, unused ────│
│ │── Mark RT1 as used ─────▶│
│ │── Generate RT2 + AT2 ───▶│
│◀── { access: AT2, │ │
│ refresh: RT2 } ──────│ │
│ │ │
│ (Later, attacker tries) │ │
│── POST /token/refresh ────▶│ │
│ { refresh_token: RT1 } │── Validate RT1 ────────▶│
│ │◀── RT1 already used! ────│
│ │── Revoke ALL tokens ────▶│
│◀── 401 Unauthorized ──────│ for this user │
Key behaviors:
- Each refresh token is single-use.
- Reuse of a refresh token triggers automatic revocation of the entire token family — a strong signal of compromise.
- Store refresh tokens hashed (bcrypt/argon2), never in plaintext.
class RefreshTokenService:
def rotate(self, old_refresh_token: str) -> TokenPair:
token_record = self.db.find_refresh_token(
hash=hash_token(old_refresh_token)
)
if token_record is None:
raise InvalidTokenError()
if token_record.used:
# Reuse detected — revoke entire family
self.db.revoke_token_family(token_record.family_id)
raise TokenReuseError()
# Mark old token as used
self.db.mark_used(token_record.id)
# Issue new pair in the same family
new_refresh = generate_refresh_token()
self.db.store_refresh_token(
hash=hash_token(new_refresh),
family_id=token_record.family_id,
user_id=token_record.user_id,
expires_at=now() + timedelta(days=7),
)
access_token = self.issue_access_token(token_record.user_id)
return TokenPair(access=access_token, refresh=new_refresh)
JWK Rotation#
Signing keys must be rotated periodically. The JSON Web Key Set (JWKS) endpoint publishes current public keys:
GET https://auth.example.com/.well-known/jwks.json
{
"keys": [
{
"kty": "RSA",
"kid": "key-2026-03",
"use": "sig",
"alg": "RS256",
"n": "0vx7agoebGcQSuu...",
"e": "AQAB"
},
{
"kty": "RSA",
"kid": "key-2026-01",
"use": "sig",
"alg": "RS256",
"n": "4cTf3qS9OjIi...",
"e": "AQAB"
}
]
}
Rotation procedure:
- Generate new key pair with a new
kid. - Publish both keys in the JWKS endpoint (old + new).
- Start signing new tokens with the new key.
- Wait for old tokens to expire (at least one access token lifetime).
- Remove the old key from the JWKS endpoint.
Verifier behavior: API servers should cache the JWKS and refresh it when they encounter a kid they do not recognize. This ensures seamless rotation without coordinated deployments.
Claims Validation Checklist#
Every JWT verifier must check these claims:
| Claim | What to validate | Risk if skipped |
|---|---|---|
exp | Token is not expired | Tokens valid forever |
iat | Issued-at is not in the future | Accepts backdated tokens |
iss | Issuer matches your auth server URL | Accepts tokens from other issuers |
aud | Audience includes your service identifier | Tokens for Service A work on B |
alg | Algorithm matches your expected algorithm | Algorithm confusion attacks |
sub | Subject exists and is active in your system | Deleted users retain access |
nbf | Not-before time has passed (if present) | Premature token use |
Common JWT Attacks#
1. Algorithm None Attack#
Some libraries accept "alg": "none" — a valid but unsigned JWT:
{ "alg": "none", "typ": "JWT" }
Defense: Always specify an explicit allowlist of algorithms:
jwt.verify(token, key, { algorithms: ['RS256'] });
2. Algorithm Confusion (RS256 to HS256)#
An attacker changes the algorithm from RS256 to HS256 and signs the token using the public key as the HMAC secret. If the server naively uses the same key for verification, it succeeds.
Defense: Never allow algorithm switching. Bind the key type to the expected algorithm.
3. JWK Injection#
The attacker embeds their own public key in the JWT header via the jwk parameter and signs with the corresponding private key.
Defense: Never trust keys embedded in the token. Always fetch keys from your trusted JWKS endpoint.
4. Token Sidejacking#
An attacker intercepts a token from an insecure channel (HTTP, logs, browser storage).
Defense:
- Transmit tokens only over HTTPS.
- Store tokens in
httpOnly,secure,SameSite=strictcookies — notlocalStorage. - Bind tokens to a client fingerprint (DPoP proof, certificate thumbprint).
5. Brute-Force HMAC Secret#
Weak HS256 secrets can be brute-forced offline since the attacker has the token.
Defense: Use secrets with at least 256 bits of entropy. Better yet, use asymmetric algorithms.
Secure JWT Architecture#
Putting it all together:
┌──────────┐ credentials ┌──────────────┐
│ Client │────────────────▶│ Auth Server │
│ │◀────────────────│ │
│ │ AT (15m) + │ - RS256 sign │
│ │ RT (7d, rotated)│ - JWKS pub │
└─────┬────┘ └──────┬───────┘
│ │
│ AT in header │ JWKS endpoint
▼ ▼
┌──────────┐ verify AT ┌──────────────┐
│ API │◀───────────────│ JWKS Cache │
│ Server │ │ (refresh on │
│ │ │ unknown kid)│
└──────────┘ └──────────────┘
Summary of best practices:
- Use RS256 or ES256 for distributed systems.
- Keep access tokens short-lived (15 minutes or less).
- Implement refresh token rotation with reuse detection.
- Rotate signing keys regularly and publish via JWKS.
- Validate all claims: exp, iss, aud, alg, sub.
- Whitelist algorithms — never accept
noneor allow algorithm switching. - Transmit tokens over HTTPS only and store in httpOnly cookies.
- Use at least 256-bit entropy for symmetric secrets.
JWTs are a powerful tool when implemented correctly. The patterns above represent the consensus of security researchers and the IETF best practices (RFC 8725). Follow them, and your authentication layer becomes a strength rather than a liability.
That is article #390 on Codelit. Browse all articles or explore the platform to level up your engineering skills.
Try it on Codelit
GitHub Integration
Paste any repo URL to generate an interactive architecture diagram from real code
Related articles
Try these templates
Build this architecture
Generate an interactive architecture for API Security in seconds.
Try it in Codelit →
Comments