How JWT Combines Base64URL, JSON, and Digital Signatures —The Full Pipeline
Every JWT you have ever seen starts as plain JSON, gets serialized, encoded, and cryptographically signed through a strict four-step pipeline. Most developers only ever interact with the final product —the opaque dot-separated string —and treat the intermediate steps as a black box.
The problem with treating JWT as a black box is that when something breaks, you have no mental model to debug it. An "invalid signature" error, a "malformed JWT" exception, or a token that decodes but fails verification all look identical from the outside.
This guide walks through the complete JWT pipeline, from raw JSON to verified token, with real code at every step. By the end, you will be able to reconstruct a JWT by hand and explain exactly what each segment means.
If you want to inspect tokens while reading, the JWT Decoder decodes the header and payload instantly and validates signatures for common algorithms.
The Pipeline in One Diagram
Raw JSON →Serialize →Base64URL Encode →Concatenate →Sign →Attach Signature
Each JWT contains exactly three segments separated by dots:
BASE64URL(UTF8(JWT-HEADER)) .
BASE64URL(UTF8(JWT-PAYLOAD)) .
BASE64URL(JWT-SIGNATURE)
The first two are encoded, not encrypted. The third is the cryptographic proof of integrity.
Step 1: Build the Header
Every JWT starts with a JSON object describing how the token was constructed:
{
"alg": "RS256",
"typ": "JWT",
"kid": "key-2026-01"
}
alg —the signing algorithm. Common values: HS256 (HMAC-SHA256), RS256 (RSA-SHA256), ES256 (ECDSA-SHA256). This is mandatory.
typ —the media type. Usually JWT. Optional but widely used for interoperability.
kid —key ID. Tells the verifier which key signed the token, useful when rotating keys.
The header is serialized to a JSON string (no extra whitespace), then Base64URL-encoded.
Why Base64URL Instead of Standard Base64?
Standard Base64 uses + and / characters, which have special meaning in URLs. Base64URL replaces them:
| Standard | URL-safe |
|---|---|
+ | - |
/ | _ |
= (padding) | Omitted |
This is why a JWT can appear in a URL query parameter or an HTTP header without additional encoding. The Base64 Encoder & Decoder supports both variants, which is useful when debugging encoding mismatches. For a deeper dive into what makes Base64URL different from standard Base64, see How JWT Uses Base64URL Encoding Explained Simply.
// JavaScript: Building the JWT header
const header = {
alg: 'RS256',
typ: 'JWT',
kid: 'key-2026-01'
};
const headerStr = JSON.stringify(header);
const headerEncoded = btoa(unescape(encodeURIComponent(headerStr)))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '');
console.log(headerEncoded);
// eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImtleS0yMDI2LTAxIn0
Step 2: Build the Payload
The payload contains the claims —statements about the user and metadata about the token itself. Standardized claims use three-letter names:
{
"sub": "user_8a3f2c",
"iss": "https://auth.devutils.com",
"aud": "api.devutils.com",
"exp": 1787059200,
"iat": 1704067200,
"role": "admin",
"scopes": ["read:users", "write:logs"]
}
Registered Claim Names
| Claim | Full Name | Purpose |
|---|---|---|
iss | Issuer | Who created the token |
sub | Subject | Who the token is about |
aud | Audience | Who should accept the token |
exp | Expiration | Expiry timestamp (seconds since epoch) |
nbf | Not Before | Token is not valid before this time |
iat | Issued At | When the token was created |
jti | JWT ID | Unique identifier for the token |
Never put secrets in the payload. JWTs are signed, not encrypted. Anyone with the token can decode the payload:
const payload = {
sub: 'user_8a3f2c',
iss: 'https://auth.devutils.com',
exp: 1787059200,
iat: 1704067200,
role: 'admin'
};
const payloadStr = JSON.stringify(payload);
const payloadEncoded = btoa(unescape(encodeURIComponent(payloadStr)))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '');
console.log(payloadEncoded);
// eyJzdWIiOiJ1c2VyXzhhM2YyYyIsImlzcyI6Imh0dHBzOi8vYXV0aC5kZXZ1dGlscy5jb20iLCJleHAiOjE3ODcwNTkyMDAsImlhdCI6MTcwNDA2NzIwMCwicm9sZSI6ImFkbWluIn0
Step 3: Create the Signing Input
The signing input is simply the encoded header and payload joined by a dot:
BASE64URL(header) . BASE64URL(payload)
No wrapping. No additional encoding. This string is what the digital signature proves has not been tampered with.
const signingInput = `${headerEncoded}.${payloadEncoded}`;
console.log(signingInput);
Step 4: Generate the Signature
The signature transforms the signing input using the algorithm specified in the header. The exact mechanism depends on the algorithm type.
HMAC (HS256, HS384, HS512)
Symmetric signing —the same secret both signs and verifies:
HMAC-SHA256(signingInput, secret)
// Node.js —HS256 signing
const crypto = require('crypto');
const secret = 'your-256-bit-secret';
const signature = crypto
.createHmac('sha256', secret)
.update(signingInput)
.digest('base64url');
console.log(signature);
// SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
RSA (RS256, RS384, RS512)
Asymmetric signing —a private key signs, a public key verifies:
RSA-SHA256(signingInput, privateKey)
// Node.js —RS256 signing
const { privateKey } = crypto.generateKeyPairSync('rsa', {
modulusLength: 2048,
});
const signature = crypto
.createSign('sha256')
.update(signingInput)
.sign(privateKey, 'base64url');
ECDSA (ES256, ES384, ES512)
Asymmetric signing with elliptic curve keys (smaller signatures):
// Node.js —ES256 signing
const { ecPrivateKey } = crypto.generateKeyPairSync('ec', {
namedCurve: 'P-256',
});
const signature = crypto
.createSign('sha256')
.update(signingInput)
.sign(ecPrivateKey, 'base64url');
Step 5: Assemble the Final JWT
The final token is the signing input plus the Base64URL-encoded signature:
const jwt = `${signingInput}.${signature}`;
console.log(jwt);
// eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImtleS0yMDI2LTAxIn0.eyJzdWIiOiJ1c2VyXzhhM2YyYyIsImlzcyI6Imh0dHBzOi8vYXV0aC5kZXZ1dGlscy5jb20iLCJleHAiOjE3ODcwNTkyMDAsImlhdCI6MTcwNDA2NzIwMCwicm9sZSI6ImFkbWluIn0.M0W8A7x...
Three segments. Two dots. One cryptographic proof.
Verification: Tracing the Pipeline Backward
Verification reverses the pipeline:
- Split the token on
. - Base64URL-decode the header and verify it is valid JSON
- Base64URL-decode the payload and verify claims (expiration, issuer, audience)
- Recompute the signature using the decoded header's algorithm
- Compare the computed signature to the attached signature
function verifyJWT(token, secretOrKey) {
const parts = token.split('.');
if (parts.length !== 3) throw new Error('Malformed JWT');
const [headerEncoded, payloadEncoded, signatureEncoded] = parts;
const signingInput = `${headerEncoded}.${payloadEncoded}`;
// Decode header to determine algorithm
const header = JSON.parse(atob(headerEncoded));
const algorithm = header.alg;
// Verify signature
const expectedSignature = crypto
.createHmac('sha256', secretOrKey)
.update(signingInput)
.digest('base64url');
if (expectedSignature !== signatureEncoded) {
throw new Error('Invalid signature');
}
// Decode and verify claims
const payload = JSON.parse(atob(payloadEncoded));
if (payload.exp && Date.now() / 1000 > payload.exp) {
throw new Error('Token expired');
}
return payload;
}
The difference between decoding and verifying is subtle but critical —see JWT Decode vs Verify for when each operation is appropriate.
Common Pipeline Failures
Algorithm Mismatch
The header says RS256 but the verifier expects HS256. This is a common configuration error between services.
Key Mismatch
The header says kid: key-2026-01 but the verifier does not have that key in its key set. Always verify that key rotation is synchronized between issuer and verifier.
Base64URL Padding Discrepancy
Some libraries add padding back during decoding; others expect it stripped. The JWT specification says padding should be omitted, but implementations vary. If a token decodes to garbage, check whether your decoder expects standard Base64 or Base64URL.
Character Encoding
Non-ASCII characters in claims must be UTF-8 encoded before Base64URL encoding. If a user's name contains accented characters, JSON serialization + btoa in JavaScript must use encodeURIComponent + unescape to produce correct UTF-8 bytes.
FAQ
Is the JWT signature also Base64URL-encoded?
Yes. The signature is raw binary output from the signing algorithm, Base64URL-encoded for transport in the third segment. Decoding it gives you the raw signature bytes, which are not human-readable.
What happens if I change one character in the payload and re-encode it?
The signature verification fails. Any modification to either the header or payload changes the signing input, which produces a different signature. The token is rejected.
Can I decode a JWT without verifying the signature?
Yes. The first two segments are Base64URL-encoded, not encrypted. Use the JWT Decoder to inspect header and payload without verification. Always verify before trusting claims from an untrusted source.
Why is padding omitted in JWT Base64URL?
Padding makes the encoded string longer without adding information. Since JWT segments are always delimited by dots, decoders can infer padding by checking length. Omitting padding reduces token size by roughly 2%.
Does JWT support encryption?
Standard JWT is signed, not encrypted. JSON Web Encryption (JWE) is a separate specification that encrypts the payload. Most applications use signed JWTs (JWS) for authentication tokens because encryption adds complexity and the payload rarely contains secrets.
Final Thoughts
The JWT pipeline —JSON serialization, Base64URL encoding, cryptographic signing —is elegant precisely because each step is simple and independently verifiable. There is no magic. A JWT is just structured data with a mathematical proof attached.
Understanding this pipeline changes how you debug token issues. An "invalid signature" is not a mysterious error from the auth gods —it means the bytes you received produce a different HMAC or RSA signature than the one attached, which narrows the search to key mismatch, algorithm mismatch, or tampered data.
When you need to inspect a token's structure, verify claims, or debug a signing issue, the JWT Decoder provides instant insight into all three segments. The Base64 Encoder & Decoder helps when encoding mismatches are the root cause. The Regex Tester is invaluable for validating any pre-processing logic you apply before signature verification. For a curated list of the best tools for inspecting and debugging tokens, check out Best JWT Decoder & Validator Tools for Developers.