JWT Deep Dive — Structure, Signing, Validation & Common Mistakes
Understand JSON Web Tokens from the inside — header, payload, signatures, HMAC vs RSA, token validation, refresh strategies, and the mistakes that lead to security breaches.
JWTs are everywhere. Login to an API? JWT. Microservice-to-microservice auth? JWT. OAuth access tokens? Often JWTs. But most developers treat them as opaque strings — generate, send, done.
Understanding how JWTs work — and how they break — is the difference between secure authentication and a security incident. This post covers the internals.
What is a JWT?
A JSON Web Token is a compact, URL-safe token format. It has three parts separated by dots:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkFsaWNlIiwiaWF0IjoxNzE5MDAwMDAwfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
Each part is Base64URL-encoded JSON:
HEADER.PAYLOAD.SIGNATURE
Header
{
"alg": "HS256",
"typ": "JWT"
}
alg is the signing algorithm. typ is always “JWT”.
Payload (Claims)
{
"sub": "1234567890",
"name": "Alice",
"email": "[email protected]",
"role": "admin",
"iat": 1719000000,
"exp": 1719003600,
"iss": "https://auth.example.com",
"aud": "https://api.example.com"
}
Standard claims:
| Claim | Purpose |
|---|---|
sub | Subject (user ID) |
iss | Issuer (who created the token) |
aud | Audience (who the token is for) |
exp | Expiration time (Unix timestamp) |
iat | Issued at (Unix timestamp) |
nbf | Not before (token not valid before this time) |
jti | JWT ID (unique identifier for the token) |
You can add custom claims (name, email, role, etc.), but keep them minimal — the payload isn’t encrypted, just encoded.
Signature
HMACSHA256(
base64UrlEncode(header) + "." + base64UrlEncode(payload),
secret
)
The signature proves the token wasn’t tampered with. The server signs it with a secret. On validation, it recomputes the signature and checks if it matches.
Signing algorithms
HMAC (symmetric)
Same key for signing and verifying:
HS256: HMAC using SHA-256
HS384: HMAC using SHA-384
HS512: HMAC using SHA-512
val algorithm = Algorithm.HMAC256("your-256-bit-secret")
val token = JWT.create()
.withSubject("user-123")
.withClaim("role", "admin")
.withIssuedAt(Date())
.withExpiresAt(Date(System.currentTimeMillis() + 3600000)) // 1 hour
.sign(algorithm)
Pros: Fast, simple. Cons: Both the issuer and verifier need the same secret. If the API server leaks the secret, anyone can forge tokens.
Use when: The same server issues and verifies tokens.
RSA (asymmetric)
Private key signs, public key verifies:
RS256: RSA using SHA-256
RS384: RSA using SHA-384
RS512: RSA using SHA-512
val privateKey = loadPrivateKey("private.pem")
val publicKey = loadPublicKey("public.pem")
// Sign (auth server)
val algorithm = Algorithm.RSA256(publicKey, privateKey)
val token = JWT.create()
.withSubject("user-123")
.sign(algorithm)
// Verify (API server — only needs public key)
val verifier = JWT.require(Algorithm.RSA256(publicKey, null))
.withIssuer("https://auth.example.com")
.build()
val decoded = verifier.verify(token)
Pros: The verifier never sees the private key. Multiple services can verify without sharing secrets. Cons: Slower than HMAC. Key management is more complex.
Use when: Tokens are verified by different services than the one that issues them (microservices, third-party APIs).
Token validation
Every time you receive a JWT, validate:
1. Signature
Verify the signature matches the payload. If someone modified the payload, the signature won’t match.
2. Expiration (exp)
if (decoded.expiresAt.before(Date())) {
throw TokenExpiredException("Token has expired")
}
3. Issuer (iss)
if (decoded.issuer != "https://auth.example.com") {
throw InvalidTokenException("Unknown issuer")
}
4. Audience (aud)
if (!decoded.audience.contains("https://api.example.com")) {
throw InvalidTokenException("Token not intended for this API")
}
Without audience validation, a token issued for Service A could be used against Service B.
5. Not Before (nbf)
If present, the token isn’t valid before this time. Prevents token use before a scheduled activation.
Full validation in Spring Boot
@Bean
fun jwtDecoder(): JwtDecoder {
val decoder = NimbusJwtDecoder.withJwkSetUri(jwksUri).build()
val validator = DelegatingOAuth2TokenValidator(
JwtTimestampValidator(),
JwtIssuerValidator("https://auth.example.com"),
JwtClaimValidator<List<String>>("aud") { audiences ->
audiences.contains("https://api.example.com")
}
)
decoder.setJwtValidator(validator)
return decoder
}
Refresh token strategy
Access tokens should be short-lived (15 minutes to 1 hour). When they expire, use a refresh token to get a new one:
1. Client authenticates → receives access_token (15 min) + refresh_token (30 days)
2. Client uses access_token for API calls
3. access_token expires → Client sends refresh_token to /auth/refresh
4. Server validates refresh_token → issues new access_token + new refresh_token
5. Old refresh_token is invalidated (rotation)
Refresh token rotation
Issue a new refresh token with every refresh. Invalidate the old one:
fun refresh(refreshToken: String): TokenPair {
val stored = refreshTokenRepository.findByToken(refreshToken)
?: throw InvalidTokenException("Invalid refresh token")
if (stored.isUsed) {
// Token reuse detected — possible theft
// Invalidate all tokens for this user
refreshTokenRepository.invalidateAllForUser(stored.userId)
throw SecurityException("Refresh token reuse detected")
}
// Mark old token as used
refreshTokenRepository.markUsed(stored.id)
// Issue new tokens
val newAccessToken = generateAccessToken(stored.userId)
val newRefreshToken = generateRefreshToken(stored.userId)
refreshTokenRepository.save(newRefreshToken)
return TokenPair(newAccessToken, newRefreshToken.token)
}
If a stolen refresh token is used after the legitimate user has already refreshed, the reuse is detected and all tokens are invalidated.
Token storage
| Platform | Where to store | Why |
|---|---|---|
| Web (server-rendered) | httpOnly, Secure, SameSite cookie | Not accessible to JavaScript (XSS-safe) |
| Web (SPA) | httpOnly cookie or in-memory | localStorage is XSS-vulnerable |
| Android | EncryptedSharedPreferences | Encrypted at rest |
| iOS | Keychain | OS-level encryption |
| Backend (M2M) | Environment variable | Never in code |
Never store tokens in:
- localStorage (XSS can read it)
- URL parameters (logged in server logs, browser history)
- Plain SharedPreferences (readable on rooted devices)
Common mistakes
1. Not validating the algorithm
The alg header tells the server which algorithm to use. An attacker can set alg: "none" (no signature required) and the server accepts it:
{ "alg": "none", "typ": "JWT" }
Fix: Always specify the expected algorithm server-side. Never trust the token’s alg claim:
// Explicitly set the algorithm — don't let the token choose
val verifier = JWT.require(Algorithm.HMAC256(secret))
.build()
2. Sensitive data in the payload
JWTs are encoded, not encrypted. Anyone can decode the payload:
echo "eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkFsaWNlIn0" | base64 -d
# {"sub":"1234567890","name":"Alice"}
Never put in a JWT: passwords, credit card numbers, social security numbers, API keys, or anything sensitive.
3. No expiration
A JWT without exp is valid forever. If stolen, the attacker has permanent access.
Fix: Always set expiration. Short-lived for access tokens (15 min – 1 hour). Use refresh tokens for long sessions.
4. Using JWTs for sessions
JWTs can’t be revoked (they’re self-contained). If a user logs out, the JWT is still valid until it expires. With server-side sessions, you delete the session and it’s immediately invalid.
If you need immediate revocation: Use a token blocklist (check a database on every request) or use short-lived tokens with refresh rotation.
5. Huge payloads
JWTs go in every HTTP request (Authorization header). A 4KB JWT on every API call adds latency and bandwidth. Keep the payload small — user ID, role, expiry. Fetch additional data from the database when needed.
When to use JWTs
Good fit:
- Stateless API authentication
- Microservice-to-microservice auth (services verify locally)
- Short-lived tokens with refresh rotation
- Single sign-on (SSO)
Not ideal:
- When you need immediate token revocation (use server-side sessions)
- When tokens contain lots of data (use opaque tokens + introspection)
- For long-lived sessions without refresh tokens
Summary
- JWTs have three parts: header, payload, signature
- Always validate: signature, expiry, issuer, audience
- Use HMAC for single-server setups, RSA for distributed verification
- Short-lived access tokens + refresh token rotation = secure sessions
- Never store sensitive data in the payload — it’s encoded, not encrypted
- Always specify the algorithm server-side — never trust the token’s
alg
JWTs are a tool. Used correctly — short-lived, properly validated, securely stored — they’re a solid foundation for authentication. Used carelessly, they’re a security hole waiting to be exploited.