PCSalt
YouTube GitHub
Back to Security
Security · 4 min read

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
{
  "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:

ClaimPurpose
subSubject (user ID)
issIssuer (who created the token)
audAudience (who the token is for)
expExpiration time (Unix timestamp)
iatIssued at (Unix timestamp)
nbfNot before (token not valid before this time)
jtiJWT 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

PlatformWhere to storeWhy
Web (server-rendered)httpOnly, Secure, SameSite cookieNot accessible to JavaScript (XSS-safe)
Web (SPA)httpOnly cookie or in-memorylocalStorage is XSS-vulnerable
AndroidEncryptedSharedPreferencesEncrypted at rest
iOSKeychainOS-level encryption
Backend (M2M)Environment variableNever 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.