Loading content...
In the evolution of web authentication, few innovations have had as profound an impact as the JSON Web Token (JWT). Before JWTs, stateful sessions dominated—servers maintained session stores, clusters required sticky sessions or distributed caches, and horizontal scaling meant complex session replication. JWTs introduced a paradigm shift: stateless authentication where the token itself carries all the information needed to verify identity and authorization.
But to use JWTs effectively and securely, you must understand them deeply. A JWT is not a magic string—it's a precisely structured, cryptographically protected data format with specific rules governing its creation, transmission, and validation. Misunderstanding this structure leads to security vulnerabilities that have compromised countless applications.
This page dissects the JWT structure with surgical precision. By the end, you'll understand every byte of a JWT, why each component exists, and how they work together to enable secure, stateless authentication at scale.
By the end of this page, you will understand the complete anatomy of JWTs: the three-part structure (Header, Payload, Signature), Base64URL encoding, how each component contributes to security, and why the JWT specification makes the design choices it does. You'll be able to read, understand, and debug any JWT you encounter.
Every JWT consists of exactly three parts, separated by periods (.):
xxxxx.yyyyy.zzzzz
↑ ↑ ↑
Header Payload Signature
This structure is not arbitrary—it's carefully designed to satisfy three requirements:
Let's examine a real JWT to understand this structure concretely:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c # Breaking it down:# Part 1 (Header): eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9# Part 2 (Payload): eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ# Part 3 (Signature): SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5cEach part is Base64URL encoded, not encrypted. This is a critical distinction:
JWT headers and payloads are merely encoded—anyone can decode and read them. The signature doesn't hide the content; it proves the content hasn't been tampered with. If you need to hide JWT contents, you must use JWE (JSON Web Encryption), a separate but related standard.
Never put sensitive data (passwords, social security numbers, credit card details) in a standard JWT. The payload is visible to anyone who intercepts the token. Base64URL encoding provides zero confidentiality—it's the equivalent of writing a secret on a postcard.
Before diving into each JWT component, we must understand Base64URL encoding—the encoding scheme that makes JWTs URL-safe and compact.
Why Base64URL instead of standard Base64?
Standard Base64 uses characters that are problematic in URLs:
+ (plus) conflicts with URL encoding for spaces/ (slash) conflicts with URL path separators= (equals) for padding conflicts with query string delimitersBase64URL solves these issues with simple substitutions:
| Standard Base64 | Base64URL |
|---|---|
+ (plus) | - (hyphen) |
/ (slash) | _ (underscore) |
= (padding) | Omitted |
This makes JWTs safe to transmit in:
123456789101112131415161718192021222324252627282930313233343536373839404142
// Base64URL encoding and decoding functions // Encode string to Base64URLfunction base64UrlEncode(data: string): string { // First, encode to standard Base64 const base64 = Buffer.from(data).toString('base64'); // Then convert to Base64URL: // 1. Replace + with - // 2. Replace / with _ // 3. Remove = padding return base64 .replace(/\+/g, '-') .replace(/\//g, '_') .replace(/=+$/, '');} // Decode Base64URL to stringfunction base64UrlDecode(encoded: string): string { // First, convert from Base64URL to standard Base64 let base64 = encoded .replace(/-/g, '+') .replace(/_/g, '/'); // Add back padding if needed // Base64 strings must have length divisible by 4 const padding = (4 - (base64.length % 4)) % 4; base64 += '='.repeat(padding); // Decode from Base64 return Buffer.from(base64, 'base64').toString('utf-8');} // Example: Decode JWT partsconst header = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9';const payload = 'eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ'; console.log('Header:', base64UrlDecode(header));// Output: {"alg":"HS256","typ":"JWT"} console.log('Payload:', base64UrlDecode(payload));// Output: {"sub":"1234567890","name":"John Doe","iat":1516239022}You can decode any JWT at jwt.io or using command-line tools. Simply paste the token, and the debugger will show you the decoded header and payload. This is invaluable for debugging authentication issues—but remember, this also means any intercepted token's contents are readable by attackers.
The Header is the first part of a JWT (before the first period). It's a JSON object that declares the token type and the signing algorithm used. This metadata is essential because the receiver must know how to verify the signature.
A typical JWT header looks like this:
1234
{ "alg": "HS256", // Algorithm used for signing "typ": "JWT" // Token type (always "JWT" for JWTs)}Required and Optional Header Parameters:
The JWT specification (RFC 7519) defines several header parameters:
| Parameter | Name | Required | Description |
|---|---|---|---|
| alg | Algorithm | Yes | Identifies the cryptographic algorithm used to secure the JWT |
| typ | Type | Recommended | Declares the media type. Should be "JWT" to indicate this is a JWT |
| cty | Content Type | No | Used for nested JWTs (JWT inside JWT). Usually omitted. |
| kid | Key ID | No | Identifies which key was used to sign. Critical for key rotation. |
| jku | JWK Set URL | No | URL to the JSON Web Key Set containing the signing key |
| x5u | X.509 URL | No | URL to the X.509 certificate containing the public key |
| x5c | X.509 Chain | No | X.509 certificate chain as an array of Base64-encoded certs |
Common Signing Algorithms:
The alg header specifies which algorithm protects the JWT. Each algorithm family has different characteristics:
The JWT spec includes an "alg": "none" option for unsigned tokens. Many libraries have had vulnerabilities where attackers could change the algorithm to "none" and strip the signature, bypassing verification entirely. Always explicitly reject the "none" algorithm in your validation logic. Never trust the alg header from an untrusted source without validation.
The Payload is the second part of a JWT (between the two periods). It contains claims—statements about the subject (typically the user) and additional metadata. Claims are the "business data" of the token.
The JWT specification defines three categories of claims:
A typical payload might look like this:
1234567891011121314151617
{ // Registered Claims (standard) "iss": "https://auth.example.com", // Issuer "sub": "user-12345", // Subject (user ID) "aud": "https://api.example.com", // Audience "exp": 1735689600, // Expiration (Unix timestamp) "nbf": 1735686000, // Not Before "iat": 1735686000, // Issued At "jti": "unique-token-id-abc123", // JWT ID (unique identifier) // Private Claims (application-specific) "user_id": "12345", "email": "john@example.com", "roles": ["user", "admin"], "permissions": ["read:users", "write:users"], "tenant_id": "org-789"}Detailed Registered Claims Reference:
| Claim | Name | Description | Example Value |
|---|---|---|---|
| iss | Issuer | Who issued the token. Usually your auth server URL. | "https://auth.example.com" |
| sub | Subject | Who the token represents. Typically user ID (unique, immutable). | "user-12345" |
| aud | Audience | Intended recipient(s). Can be string or array of strings. | "https://api.example.com" |
| exp | Expiration | When the token expires. Unix timestamp. MUST be validated. | 1735689600 |
| nbf | Not Before | Token not valid before this time. Unix timestamp. | 1735686000 |
| iat | Issued At | When the token was created. Unix timestamp. | 1735686000 |
| jti | JWT ID | Unique identifier for the token. Prevents replay attacks. | "abc123-xyz789" |
You'll notice registered claims use 3-letter abbreviations (iss, sub, aud, exp, etc.). This is deliberate—JWTs are transmitted with every request, often in headers with size limits. Short claim names reduce token size. For custom claims, consider similar brevity while maintaining clarity.
Best Practices for Payload Design:
Keep payloads small: Every byte increases request overhead. Include only what's needed for authorization decisions.
Use registered claims correctly: Don't reinvent exp with expiration_time—use the standard claims for interoperability.
Make sub immutable: The subject should never change. Use internal user IDs, not emails or usernames that might change.
Consider claim sensitivity: Remember, payload content is visible. Use IDs, not full names or emails, when possible.
Validate aud claims: Your API should verify it's the intended audience, rejecting tokens meant for other services.
Include authorization info carefully: Roles/permissions in tokens are convenient but can't be revoked until expiration.
The Signature is the third and final part of a JWT (after the second period). It's the cryptographic mechanism that ensures:
The signature is computed over the encoded header and payload, creating a cryptographic "seal" that can be verified by anyone with the appropriate key.
Signature Computation:
# Signature computation formula: Signature = Algorithm( Base64UrlEncode(Header) + "." + Base64UrlEncode(Payload), Secret or Private Key) # For HMAC-SHA256 (HS256):HMACSHA256( base64UrlEncode(header) + "." + base64UrlEncode(payload), secret) # For RSA-SHA256 (RS256):RSASHA256( base64UrlEncode(header) + "." + base64UrlEncode(payload), privateKey) # The signature is then Base64URL encoded to form the third JWT partWhy This Design Works:
The signature covers both the header AND the payload. This means:
12345678910111213141516171819202122232425262728293031323334353637383940
import * as crypto from 'crypto'; // Verify JWT signature (HS256 example)function verifyJWTSignature(jwt: string, secret: string): boolean { // Split the JWT into its three parts const parts = jwt.split('.'); if (parts.length !== 3) { throw new Error('Invalid JWT format: must have 3 parts'); } const [encodedHeader, encodedPayload, providedSignature] = parts; // Recreate the signature using the header.payload and secret const data = `${encodedHeader}.${encodedPayload}`; const expectedSignature = crypto .createHmac('sha256', secret) .update(data) .digest('base64url'); // Node.js 16+ supports base64url directly // Use timing-safe comparison to prevent timing attacks const sigBuffer = Buffer.from(providedSignature); const expectedBuffer = Buffer.from(expectedSignature); if (sigBuffer.length !== expectedBuffer.length) { return false; } return crypto.timingSafeEqual(sigBuffer, expectedBuffer);} // Example usageconst token = 'eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ1c2VyLTEifQ.xxx';const secret = 'your-256-bit-secret'; if (verifyJWTSignature(token, secret)) { console.log('Token signature is valid'); // Now safe to parse and trust the payload} else { console.log('Token signature is INVALID - reject this token!');}Never use simple string equality (===) to compare signatures. Attackers can exploit timing differences to guess the correct signature byte-by-byte. Always use timing-safe comparison functions like crypto.timingSafeEqual() in Node.js or hmac.compare_digest() in Python.
Now that we understand each component, let's trace the complete lifecycle of a JWT from creation to verification. Understanding this flow is essential for debugging authentication issues and implementing secure systems.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105
import jwt from 'jsonwebtoken'; // ========================================// PHASE 1: Token Creation (Auth Server)// ======================================== interface UserClaims { userId: string; email: string; roles: string[];} function createAccessToken(user: UserClaims, secret: string): string { // Build the payload with registered and custom claims const payload = { // Registered claims iss: 'https://auth.example.com', // Our auth server sub: user.userId, // User's unique ID aud: 'https://api.example.com', // Target API iat: Math.floor(Date.now() / 1000), // Issued now exp: Math.floor(Date.now() / 1000) + 3600, // Expires in 1 hour jti: crypto.randomUUID(), // Unique token ID // Custom claims email: user.email, roles: user.roles, }; // Sign with HS256 (symmetric) // The library handles: header creation, encoding, signature return jwt.sign(payload, secret, { algorithm: 'HS256' });} // Create a tokenconst accessToken = createAccessToken( { userId: 'user-123', email: 'john@example.com', roles: ['user'] }, process.env.JWT_SECRET!); console.log('Generated Token:', accessToken);// eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJodHRwczovL2... // ========================================// PHASE 2: Token Transmission (Client)// ======================================== // Client stores token (memory, sessionStorage, or httpOnly cookie)// Client sends token with each API request: fetch('https://api.example.com/users/me', { headers: { 'Authorization': `Bearer ${accessToken}`, 'Content-Type': 'application/json', },}); // ========================================// PHASE 3: Token Verification (API Server)// ======================================== interface VerifiedPayload extends jwt.JwtPayload { email: string; roles: string[];} function verifyAccessToken(token: string, secret: string): VerifiedPayload { try { // jwt.verify() does everything: // 1. Splits the token // 2. Verifies the signature // 3. Checks exp (expiration) // 4. Checks nbf (not before) if present // 5. Returns the payload if valid const payload = jwt.verify(token, secret, { algorithms: ['HS256'], // Only accept HS256 issuer: 'https://auth.example.com', // Verify issuer audience: 'https://api.example.com', // Verify audience clockTolerance: 30, // Allow 30s clock skew }) as VerifiedPayload; return payload; } catch (error) { if (error instanceof jwt.TokenExpiredError) { throw new Error('Token has expired'); } if (error instanceof jwt.JsonWebTokenError) { throw new Error('Invalid token: ' + error.message); } throw error; }} // Middleware exampleasync function authMiddleware(req: Request): Promise<VerifiedPayload> { const authHeader = req.headers.get('Authorization'); if (!authHeader?.startsWith('Bearer ')) { throw new Error('Missing or invalid Authorization header'); } const token = authHeader.slice(7); // Remove 'Bearer ' prefix return verifyAccessToken(token, process.env.JWT_SECRET!);}When verifying JWTs, always explicitly specify the allowed algorithms. Never let the token's header dictate which algorithm to use—this is the root cause of many JWT vulnerabilities. Reject any token that doesn't use your expected algorithm.
Choosing between symmetric (HMAC) and asymmetric (RSA/ECDSA) signing is one of the most important architectural decisions when implementing JWTs. Each approach has distinct security properties and operational implications.
| Aspect | Symmetric (HS256/HS384/HS512) | Asymmetric (RS256/ES256) |
|---|---|---|
| Key Model | Single shared secret | Public/private key pair |
| Who Can Sign | Anyone with the secret | Only the holder of private key |
| Who Can Verify | Anyone with the secret (same key) | Anyone with public key (different key) |
| Key Distribution | Must be kept secret everywhere | Public key can be freely distributed |
| Performance | ~10x faster (pure hash operations) | Slower (complex math operations) |
| Key Size | ≥256 bits for HS256 | ≥2048 bits for RS256, 256 bits for ES256 |
| Token Size | Typically 200-500 bytes | 300-600 bytes (larger signature) |
| Key Rotation | Requires secure redistribution | Only need to redistribute public key |
ECDSA (ES256, ES384, ES512) provides asymmetric security with much better performance than RSA. ES256 signatures are only 64 bytes vs 256+ bytes for RS256, and verification is significantly faster. For new systems, ES256 is often the best choice for asymmetric signing.
We've dissected the JWT structure with the precision required for secure implementation. Let's consolidate the essential knowledge:
alg (signing algorithm) and typ (token type), telling verifiers how to validate the signatureWhat's Next:
Now that you understand JWT structure, the next page explores Claims and Validation in depth. We'll cover how to design claim schemas for real applications, implement comprehensive validation, and avoid common mistakes that lead to security vulnerabilities.
You now have a complete understanding of JWT structure—the three-part architecture, Base64URL encoding, header parameters, registered and custom claims, and signature computation. This foundation is essential for secure JWT implementation. Next, we'll dive deep into claims design and validation patterns.