Back to JWT: Structure, Validation, Storage
Q2Question 2 of 4

How JWT Validation Works and Why Never Store Sensitive Data in the Payload

Key Concepts
  • Signature validation: the server recomputes the signature over the header and payload using the configured key and compares it to the token's signature.
  • Expiry validation: the server checks the exp claim against the current time, rejecting expired tokens.
  • Issuer and audience validation: the server confirms iss and aud match expected values to prevent cross-service replay.
  • Algorithm pinning: the server must enforce the expected algorithm rather than trusting the header's alg field to prevent alg:none attacks.
  • The payload is only Base64Url-encoded, not encrypted, so anyone who captures the token can read every claim.
  • Never store passwords, card numbers, balances, or PII in a JWT: fetch sensitive data server-side after authorization.

Definition

Validate the signature, the expiry, and the issuer before you trust a single claim: and never write a secret on something everyone can read.
  • 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 exp claim against the current time, usually with a small clock-skew allowance, and rejects expired tokens.
  • Issuer/audience validation – the server confirms iss equals the trusted issuer and aud includes the current service.
  • Algorithm pinning – the server enforces the expected algorithm rather than trusting the token's alg header, preventing alg:none and 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.

JAVA
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());
        }
    }
}
TEXT
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.

JAVA
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());
        }
    }
}
TEXT
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.

JAVA
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");
        }
    }
}
TEXT
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.

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

  1. Decode the header and check the algorithm matches the expected one.
  2. Verify the signature using the pinned key.
  3. Check exp against the current time (with clock-skew tolerance).
  4. Check iss matches the trusted issuer.
  5. Check aud includes the current service.
  6. Optionally check nbf (not before) to reject tokens used too early.
  7. Optionally check jti against a revocation denylist for high-risk operations.
JAVA
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.

JAVA
// 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.
    }
}
TEXT
{"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.

JAVA
// 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

DIAGRAM
  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.

---

Share
Use ← → arrow keys to navigate