JWT, OAuth2 & Securing APIs

JSON Web Tokens (JWT) Explained

18 min Lesson 2 of 13

JSON Web Tokens (JWT) Explained

In the previous lesson you saw why stateless authentication is the right model for REST APIs. This lesson zooms in on the token format that makes it possible: the JSON Web Token. By the time you finish you will be able to read any JWT with your own eyes, know exactly what belongs inside it and why, and recognise the security implications of every design choice.

What a JWT Looks Like

A JWT is a compact, URL-safe string divided into exactly three Base64URL-encoded sections separated by dots:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9 . eyJzdWIiOiJ1c2VyLTQyIiwicm9sZXMiOlsiUk9MRV9VU0VSIl0sImlhdCI6MTcxNzAwMDAwMCwiZXhwIjoxNzE3MDAzNjAwfQ . SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c

The three parts are: Header · Payload · Signature. Strip away the Base64URL encoding and you get two JSON objects and a binary blob.

Part 1 — The Header

The header is a small JSON object that describes the token itself — not the user, not the permissions, just the token format.

{ "alg": "HS256", "typ": "JWT" }

The two mandatory fields are:

  • alg — the cryptographic algorithm used to create the signature. Common values are HS256 (HMAC-SHA-256, a symmetric algorithm using a shared secret) and RS256 (RSA-SHA-256, an asymmetric algorithm using a private/public key pair).
  • typ — the token type. It is always "JWT" for JSON Web Tokens.
The alg: none attack. A notorious vulnerability in early JWT libraries allowed attackers to set "alg": "none", causing the library to skip signature verification entirely. Always configure your library to accept only the specific algorithms your application uses — never allow none in production.

Part 2 — The Payload (Claims)

The payload is the heart of the token. It is a JSON object containing claims — statements about the subject (usually the logged-in user) and metadata about the token itself. Claims are divided into three categories.

Registered Claims

These are standardised names defined in RFC 7519. Using them consistently lets different systems interoperate. The most important ones for a Spring Security application:

  • sub (subject) — a unique identifier for the principal the token represents, typically a user ID or username.
  • iss (issuer) — who minted the token, e.g. "https://api.myapp.com". Resource servers should reject tokens from unexpected issuers.
  • aud (audience) — the intended recipient(s) of the token. A token minted for "mobile-app" should be rejected by the "admin-dashboard" service.
  • iat (issued at) — a Unix timestamp recording when the token was created.
  • exp (expiration time) — a Unix timestamp after which the token must be rejected. This is your first line of defence: short-lived tokens drastically limit the damage if a token is stolen.
  • nbf (not before) — a Unix timestamp before which the token must be rejected. Rarely used but occasionally needed for deferred-activation scenarios.
  • jti (JWT ID) — a unique identifier for the token. Storing and checking jti values lets you implement token revocation without a full session store.

Public Claims

Application-specific claims that you register in the IANA JWT Claims Registry to avoid collisions, or that you namespace with a URI: "https://myapp.com/roles". In practice, most teams skip formal registration for internal services.

Private Claims

Claims agreed upon between producer and consumer — not registered anywhere. Typical examples: roles, tenantId, plan. These are the claims your Spring Security filter will read to build the Authentication object.

A realistic payload for a multi-tenant SaaS API:

{ "sub": "user-42", "iss": "https://auth.myapp.com", "aud": "api.myapp.com", "iat": 1717000000, "exp": 1717003600, "roles": ["ROLE_USER", "ROLE_BILLING_ADMIN"], "tenantId": "acme-corp", "plan": "pro" }
The payload is NOT encrypted — only signed. Base64URL encoding is trivially reversible. Anyone who holds the token can decode the payload and read every claim. Never put a password, credit card number, secret key, or any sensitive PII in a JWT payload. The signature proves the payload has not been tampered with; it does not hide the data.

Part 3 — The Signature

The signature binds the header and payload together and proves they were produced by a party holding the correct key. It is computed over the encoded header and payload:

HMACSHA256( base64UrlEncode(header) + "." + base64UrlEncode(payload), secret )

For RS256 the process is the same except the signer uses their private key and verifiers use the matching public key. This asymmetry is central to microservice security:

  • Only the Auth Service knows the private key — so only it can mint tokens.
  • Every downstream microservice holds only the public key — it can verify tokens but never forge them.
  • Compromising a downstream service does not compromise the Auth Service's signing key.
Prefer RS256 (or ES256) in distributed systems. With HS256 every service that needs to verify tokens must share the same secret — which means every service is as dangerous as the Auth Service if compromised. With RS256 you distribute only a public key; the attack surface is dramatically smaller.

Putting It All Together — Encoding and Decoding by Hand

Understanding the encoding removes the "magic". Here is what happens when a token is created and when it is verified, expressed as pseudocode in Java terms:

// CREATION (Auth Service) String headerJson = "{\"alg\":\"HS256\",\"typ\":\"JWT\"}"; String payloadJson = "{\"sub\":\"user-42\",\"exp\":1717003600,...}"; String encodedHeader = Base64.getUrlEncoder().withoutPadding() .encodeToString(headerJson.getBytes(StandardCharsets.UTF_8)); String encodedPayload = Base64.getUrlEncoder().withoutPadding() .encodeToString(payloadJson.getBytes(StandardCharsets.UTF_8)); String signingInput = encodedHeader + "." + encodedPayload; byte[] signature = HMAC_SHA256(signingInput, secretKey); String encodedSig = Base64.getUrlEncoder().withoutPadding() .encodeToString(signature); String jwt = encodedHeader + "." + encodedPayload + "." + encodedSig; // VERIFICATION (Resource Server) String[] parts = jwt.split("\\."); // [header, payload, sig] byte[] expectedSig = HMAC_SHA256(parts[0]+"."+parts[1], secretKey); byte[] actualSig = Base64.getUrlDecoder().decode(parts[2]); if (!MessageDigest.isEqual(expectedSig, actualSig)) { throw new SecurityException("Signature invalid — token has been tampered with"); } // Only now decode and trust the payload claims
Use a constant-time comparison. The code above calls MessageDigest.isEqual() for a reason. A naive Arrays.equals() short-circuits on the first mismatch, leaking timing information that an attacker can exploit to forge signatures byte by byte. Always use a constant-time equality check when comparing cryptographic values.

Key Design Trade-offs Every Developer Must Know

Before adding a claim or tweaking expiry times, understand these trade-offs:

  • Token size vs claim richness. Every claim you add grows the token, which travels as a header on every HTTP request. Keep payloads lean — IDs and roles are fine; full user profile objects are not.
  • Expiry vs revocation. JWTs are stateless, so there is no built-in revocation. Once issued, a token is valid until it expires. A one-hour expiry means a stolen token can be used for up to one hour. A ten-minute expiry cuts that window but forces more refresh-token roundtrips. Balance UX against risk.
  • JTI-based revocation. If you need immediate revocation (logout, compromise response), store a blocklist of jti values in Redis or a database, checked on every request. This reintroduces state but only for blocked tokens — the happy path remains stateless.
  • Clock skew. Servers in a distributed system rarely have perfectly synchronised clocks. Libraries like JJWT and Nimbus let you configure an allowed clock skew (usually 60 seconds) so that a token with exp = T is still accepted a few seconds past T.

Summary

A JWT is three Base64URL-encoded sections: a header describing the algorithm, a payload containing registered and custom claims, and a signature that cryptographically binds the other two. The payload is readable by anyone but tamper-proof. Use HS256 for single-service systems and RS256/ES256 for microservices. Keep payloads small, set short expiry times, and never put sensitive data in a token. With this foundation in place, the next lesson shows you how to generate and validate JWTs in a real Spring Boot 3 application using the JJWT library.