Definition
- Signature validation – the server recomputes the signature over the header and payload using the expected key and pinned algorithm, then compares it to the signature in the token.
- Expiry validation – the server checks the
expclaim against the current time, usually with a small clock-skew allowance, and rejects expired tokens. - Issuer/audience validation – the server confirms
issequals the trusted issuer andaudincludes the current service. - Algorithm pinning – the server enforces the expected algorithm rather than trusting the token's
algheader, preventingalg:noneand algorithm-confusion attacks. - No confidentiality – the payload is Base64Url-encoded, not encrypted, so sensitive data must never be stored in it.
When a cheque arrives at National Bank of India, the clerk runs several checks before paying: is the bank's signature genuine (signature check), is the cheque still within its validity date (expiry check), was it issued by our own bank and not a fake branch (issuer check). Only when all checks pass does the clerk pay out. And because the cheque's details are visible to anyone who holds it, the bank never prints a customer's PIN on it.
Understanding the Concepts
How does signature validation work?
Signature validation is the first and most critical check. The server takes the token's header and payload segments, recomputes the signature using the configured key, and compares it to the signature in the token. If they do not match, the token was tampered with or forged.
The key point is that the server uses its own configured key and algorithm, not the algorithm specified in the token's header. This prevents the alg:none attack where an attacker sets the algorithm to none and sends an unsigned token.
package com.bank.security;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.security.Keys;
import io.jsonwebtoken.security.SignatureException;
import javax.crypto.SecretKey;
import java.util.Date;
public class SignatureValidation {
private static final SecretKey KEY = Keys.hmacShaKeyFor(
"national-bank-secret-key-256-bits-long!!!".getBytes());
public static void main(String[] args) {
String goodToken = Jwts.builder()
.subject("rajesh")
.claim("role", "CUSTOMER")
.issuer("national-bank")
.signWith(KEY)
.compact();
// Good token: signature matches
try {
var claims = Jwts.parser().verifyWith(KEY).build()
.parseSignedClaims(goodToken).getPayload();
System.out.println("VALID: " + claims.getSubject());
} catch (Exception e) {
System.out.println("REJECTED: " + e.getMessage());
}
// Tampered token: payload changed
String[] parts = goodToken.split("\\.");
String tampered = parts[0] + "." + "eyJzdWIiOiJhZG1pbiJ9" + "." + parts[2];
try {
Jwts.parser().verifyWith(KEY).build().parseSignedClaims(tampered);
System.out.println("VALID: accepted");
} catch (SignatureException e) {
System.out.println("REJECTED: " + e.getMessage());
}
}
}
VALID: rajesh
REJECTED: JWT signature does not match locally computed signature.
How does expiry validation work?
The server checks the exp (expiration) claim against the current time. If now > exp, the token is expired and rejected. Most libraries allow a small clock-skew tolerance to handle minor time differences between servers.
package com.bank.security;
import io.jsonwebtoken.ExpiredJwtException;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.security.Keys;
import javax.crypto.SecretKey;
import java.util.Date;
public class ExpiryValidation {
private static final SecretKey KEY = Keys.hmacShaKeyFor(
"national-bank-secret-key-256-bits-long!!!".getBytes());
public static void main(String[] args) {
// Create an already-expired token (expiry in the past)
String expiredToken = Jwts.builder()
.subject("rajesh")
.expiration(new Date(System.currentTimeMillis() - 60_000)) // 1 min ago
.signWith(KEY)
.compact();
try {
Jwts.parser().verifyWith(KEY)
.clockSkewSeconds(5) // allow 5 seconds of clock skew
.build()
.parseSignedClaims(expiredToken);
System.out.println("Token accepted.");
} catch (ExpiredJwtException e) {
System.out.println("REJECTED: Token expired at " + e.getClaims().getExpiration());
}
}
}
REJECTED: Token expired at Sun Oct 27 09:59:00 IST 2024
How does issuer and audience validation work?
The iss (issuer) claim confirms who minted the token. The aud (audience) claim confirms who the token is intended for. If a token from a different system or for a different service is presented, validation fails.
This prevents cross-service replay: a token issued for the payment service cannot be used against the account service if the audience does not match.
package com.bank.security;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.security.Keys;
import io.jsonwebtoken.MissingClaimException;
import javax.crypto.SecretKey;
public class IssuerAudienceValidation {
private static final SecretKey KEY = Keys.hmacShaKeyFor(
"national-bank-secret-key-256-bits-long!!!".getBytes());
public static void main(String[] args) {
String token = Jwts.builder()
.subject("rajesh")
.issuer("national-bank")
.audience().add("account-service").and()
.signWith(KEY)
.compact();
// Correct issuer and audience: accepted
try {
var claims = Jwts.parser().verifyWith(KEY)
.requireIssuer("national-bank")
.requireAudience("account-service")
.build()
.parseSignedClaims(token).getPayload();
System.out.println("VALID: issuer=" + claims.getIssuer());
} catch (Exception e) {
System.out.println("REJECTED: " + e.getMessage());
}
// Wrong issuer: rejected
try {
Jwts.parser().verifyWith(KEY)
.requireIssuer("hdfc-bank") // different issuer!
.build()
.parseSignedClaims(token);
} catch (MissingClaimException e) {
System.out.println("REJECTED: wrong issuer");
}
}
}
VALID: issuer=national-bank
REJECTED: wrong issuer
What is the alg:none attack and algorithm confusion?
The alg:none attack exploits libraries that trust the token's header alg field. An attacker sets alg to none, omits the signature, and the library accepts the token without any verification.
Algorithm confusion is a similar attack where the attacker changes alg from RS256 to HS256 and uses the RSA public key (which is publicly known) as the HMAC secret to forge a valid signature.
The fix is to always pin the algorithm and key on the server side.
package com.bank.security;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.security.Keys;
import javax.crypto.SecretKey;
public class AlgorithmPinning {
// WRONG: trusting whatever alg the token specifies
// parser().build().parse(token) // dangerous!
// CORRECT: pin the key and algorithm on the server
private static final SecretKey KEY = Keys.hmacShaKeyFor(
"national-bank-secret-key-256-bits-long!!!".getBytes());
public void validateSafely(String token) {
// verifyWith pins the key, which implies the algorithm
Jwts.parser()
.verifyWith(KEY) // only accepts HS256 with this key
.requireIssuer("national-bank")
.build()
.parseSignedClaims(token); // rejects unsigned or wrong-alg tokens
}
}
What is the full validation pipeline?
A production-grade JWT validation pipeline runs these checks in order:
- Decode the header and check the algorithm matches the expected one.
- Verify the signature using the pinned key.
- Check
expagainst the current time (with clock-skew tolerance). - Check
issmatches the trusted issuer. - Check
audincludes the current service. - Optionally check
nbf(not before) to reject tokens used too early. - Optionally check
jtiagainst a revocation denylist for high-risk operations.
package com.bank.security;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.security.Keys;
import javax.crypto.SecretKey;
public class FullValidationPipeline {
private static final SecretKey KEY = Keys.hmacShaKeyFor(
"national-bank-secret-key-256-bits-long!!!".getBytes());
public Claims validate(String token) {
return Jwts.parser()
.verifyWith(KEY) // 1-2: algorithm + signature
.requireIssuer("national-bank") // 4: issuer
.requireAudience("account-service") // 5: audience
.clockSkewSeconds(30) // 3: expiry with tolerance
.build()
.parseSignedClaims(token)
.getPayload();
// Steps 6-7 (nbf, jti denylist) checked separately if needed
}
}
Why should you never store sensitive data in the payload?
The payload is only Base64Url-encoded. Anyone who captures the token can decode and read every claim without any key. A JWT provides integrity and authenticity, not confidentiality.
If a balance, a PIN, a card number, or personal identifiers are in the payload, any party who captures the token in a log, a browser history, or a proxy can read them.
// Anyone can decode a JWT payload
import java.util.Base64;
public class WhyNotEncrypted {
public static void main(String[] args) {
String payload = "eyJzdWIiOiJyYWplc2giLCJiYWxhbmNlIjoiMTI1MDAwLjc1In0";
String decoded = new String(Base64.getUrlDecoder().decode(payload));
System.out.println(decoded);
// {"sub":"rajesh","balance":"125000.75"}
// Balance leaked! No key needed to read it.
}
}
{"sub":"rajesh","balance":"125000.75"}
What are common mistakes?
The first mistake is trusting the token's alg header instead of pinning the algorithm server-side. This opens alg:none and algorithm-confusion attacks.
The second mistake is not validating iss and aud, which allows tokens minted for one service to be replayed against another.
// WRONG: no issuer or audience check
Jwts.parser().verifyWith(key).build().parseSignedClaims(token);
// A token from any issuer for any audience passes!
// CORRECT: full validation
Jwts.parser()
.verifyWith(key)
.requireIssuer("national-bank")
.requireAudience("account-service")
.clockSkewSeconds(30)
.build()
.parseSignedClaims(token);
What should a senior engineer know?
Senior engineers maintain a jti denylist for high-risk operations. Even though JWTs are stateless, operations like large transfers or account closures should check a small Redis-based denylist to catch revoked tokens before expiry.
Senior engineers also understand JWKS (JSON Web Key Set) for key rotation: the auth server publishes its public keys at a well-known endpoint, and resource servers cache and refresh them periodically, enabling seamless key rotation without downtime.
Visual Overview
JWT Validation Pipeline:
┌─────────────────────┐
│ Incoming JWT Token │
└──────────┬──────────┘
│
▼
┌─────────────────────┐
│ 1. Decode Header │── alg matches pinned? ── No ──► REJECT
└──────────┬──────────┘
│ Yes
▼
┌─────────────────────┐
│ 2. Verify Signature │── Recompute with key ── Mismatch ──► REJECT
└──────────┬──────────┘
│ Match
▼
┌─────────────────────┐
│ 3. Check exp │── now > exp? ── Yes ──► REJECT (expired)
└──────────┬──────────┘
│ No
▼
┌─────────────────────┐
│ 4. Check iss │── Wrong issuer? ── Yes ──► REJECT
└──────────┬──────────┘
│ Match
▼
┌─────────────────────┐
│ 5. Check aud │── Wrong audience? ── Yes ──► REJECT
└──────────┬──────────┘
│ Match
▼
TOKEN TRUSTED
(read claims)
Key Takeaway
JWT validation is the sequence of checks a server performs before it trusts any claim in a token. First it verifies the signature by recomputing it with the server's configured key and pinned algorithm, which proves the token was not tampered with and really came from the issuer. Then it checks the expiration claim exp against the current time with a small clock-skew allowance. Then it checks the issuer claim iss, and usually the audience aud, so a token minted elsewhere cannot be replayed against this service. Only when all of these pass are the claims treated as authoritative.
In the National Bank of India project, every service runs this pipeline, and the gateway runs it too for defense in depth. A good token passes all checks, a tampered token fails the signature check, an expired token fails the expiry check, and a token from a fake issuer fails the issuer check. The algorithm is pinned on the server rather than trusting the header's alg field, because blindly trusting it opens the alg-none and algorithm-confusion attacks.
The reason sensitive data must never appear in the payload is that a JWT gives integrity and authenticity but not confidentiality. The payload is only Base64Url-encoded, so anyone who captures the token in a log, a proxy, or a browser history can decode and read every claim. If a balance, a PIN, or a card number were in there, it would leak the moment the token did.
So in a bank the payload carries only a subject identifier, a role or scopes, and timing claims, and anything sensitive is fetched from the database only after authorization. For high-risk actions, a small revocation denylist by jti is checked, and if a payload genuinely must be confidential, JWE encryption is used rather than a plain signed token.
Remember one sentence: verify signature, check expiry, check issuer, pin the algorithm, and never put secrets in a payload that anyone can decode.
---