How JWT Uses Base64 Encoding — A Practical Explanation

The first time I inspected a JWT payload, I expected to see ciphertext. Instead, I saw this:

{
  "sub": "1234567890",
  "name": "John Doe",
  "iat": 1516239022
}

Readable. Clear. Not encrypted.

I had been treating JWTs like opaque secure blobs for months. Turns out anyone with the token can decode two-thirds of it with a single function call.

That realization changes how you think about what goes inside a JWT payload — and what absolutely shouldn't.

JWT Structure: Three Segments, Two Encodings

A JWT looks like this:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c

The dots separate three segments:

Header (Base64URL encoded JSON)

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

Tells the verifier which algorithm to use. No secrets here — this is public metadata.

Payload (Base64URL encoded JSON)

{
  "sub": "1234567890",
  "name": "John Doe",
  "iat": 1516239022
}

The claims. User identity, token expiration, permissions — whatever the issuer decided to include. Also Base64URL encoded, meaning anyone with the token can decode it.

Signature (Binary, Base64URL encoded)

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

This is the security layer. The signature proves two things:

  • The token was issued by someone with the signing key (authenticity)
  • The header and payload haven't been modified since signing (integrity)

It does not prove that only authorized parties can read the payload.

Why JWT Chose Base64URL

JWTs are designed to travel through:

  • HTTP Authorization headers
  • URL query parameters (?token=...)
  • Cookies
  • OAuth redirect callbacks

Raw JSON contains characters that break in these contexts: {, }, ", spaces, and line breaks. Base64URL converts JSON into a compact ASCII string that survives every transport channel JWTs might pass through.

JWT uses Base64URL specifically (not standard Base64) because +, /, and = cause problems in URLs. Base64URL replaces + with -, / with _, and drops padding.

The Security Model People Get Wrong

This is the critical distinction:

PropertyProvided by
Integrity (data not modified)Signature (HMAC, RSA, ECDSA)
Authenticity (from trusted issuer)Signature verification with secret/public key
Confidentiality (data not readable)Not provided by default

A signed JWT tells you the payload is genuine. It does not hide the payload. If you want confidentiality, you need JWE (JSON Web Encryption), which wraps the JWT in an encrypted envelope. Most real-world JWT implementations don't use JWE because signed-but-readable tokens are sufficient for the vast majority of authentication and authorization use cases.

What Should Never Go in a JWT Payload

Because the payload is readable by anyone who possesses the token:

  • Passwords — even hashed ones
  • API keys or secrets — the token itself is the credential
  • Internal system identifiers — database IDs, server names, network topology
  • Personally identifiable information — anything subject to GDPR or similar regulations, unless you have a specific legal basis and understand the implications

What belongs in a JWT payload:

  • User ID (a non-sensitive identifier, e.g., a UUID)
  • Token expiration (exp)
  • Issued-at time (iat)
  • Scope or permissions (scope: "read:orders")
  • Token issuer (iss)

Keep payloads small. JWTs are sent with every request. A 4KB payload adds up across thousands of API calls.

Decoding JWT Segments in Practice

// Split and decode JWT segments
const [headerB64, payloadB64, signature] = token.split(".");

function decodeJwtSegment(segment) {
  // Base64URL → Base64
  let base64 = segment.replace(/-/g, "+").replace(/_/g, "/");
  // Restore padding
  while (base64.length % 4) base64 += "=";
  // Decode
  return JSON.parse(atob(base64));
}

const header = decodeJwtSegment(headerB64);
const payload = decodeJwtSegment(payloadB64);

console.log(payload);
// { sub: "1234567890", name: "John Doe", iat: 1516239022 }

Or skip the manual decoding entirely — the JWT Decoder splits, decodes, and validates tokens in one step, including expiration checking and signature verification for HS256, RS256, and ES256.

Common JWT Debugging Problems

"Algorithm Mismatch" Errors

The alg field in the header says RS256 but your code is verifying with an HMAC secret. Or vice versa. This happens a lot when switching between identity providers or copying JWT config between projects.

Decode the header first and check alg before you touch the signature verification code.

Expired Tokens with Clock Skew

The exp claim is in Unix epoch seconds. If your server's clock is off by even a minute, tokens near expiration will fail validation inconsistently. Add a small clock skew tolerance (30-60 seconds) to your JWT verification logic.

Payload That Won't Decode

If atob() throws on a JWT segment, it's almost always one of two things: Base64URL characters that weren't converted (- and _), or missing padding. See How to Fix Invalid Base64 String Errors for the full debugging workflow.

Token Too Large for Headers

Some proxies and load balancers cap HTTP header sizes at 8KB. If your JWT contains large permission arrays or nested profile objects, you'll see mysterious 431 Request Header Fields Too Large errors. Reduce the payload or move bulk data to a separate API call.

Verifying JWT Signatures

Decoding a JWT tells you what's inside. Verifying tells you whether to trust it.

// Node.js with jsonwebtoken
const jwt = require("jsonwebtoken");

try {
  const verified = jwt.verify(token, secret, { algorithms: ["HS256"] });
  // verified contains the trusted payload
} catch (err) {
  // Token is expired, malformed, or tampered with
}

Verification checks:

  1. The signature was computed with the expected key
  2. The token hasn't expired (exp)
  3. The token isn't being used before its valid time (nbf)
  4. The issuer matches expectations (iss)
  5. The audience matches expectations (aud)

FAQ

Is a JWT encrypted?

No. Standard JWTs are signed, not encrypted. The header and payload are Base64URL encoded, which means anyone with the token can decode and read them. Encryption requires JWE, which is less commonly used.

What does the JWT signature actually protect?

Integrity and authenticity — it proves the token was created by the key holder and hasn't been tampered with. If an attacker changes "role": "user" to "role": "admin", the signature verification fails. It does not prevent reading the payload.

Why can't I decode just the JWT signature?

The signature is binary (the output of HMAC or an asymmetric signing algorithm), not JSON. It's Base64URL encoded for transport but doesn't decode to human-readable text. You don't need to inspect it — verification handles that automatically.

What's the difference between JWT signing and JWT encryption?

Signing (JWS) proves origin and integrity. The payload is readable. Encryption (JWE) makes the payload unreadable to anyone without the decryption key. Most applications only need signing.

Should I store JWTs in localStorage?

Generally, no. localStorage is accessible to any JavaScript running on the page, including third-party scripts and XSS payloads. HttpOnly cookies are more secure because JavaScript can't read them. If you must use client-side storage, understand the XSS risk and mitigate it with a strong Content Security Policy.


If you debug JWTs regularly — checking claims, verifying expiration, or inspecting token structure — the JWT Decoder decodes headers and payloads instantly and validates signatures for HS256, RS256, and ES256 algorithms. Pair it with the Base64 Encoder & Decoder for manual segment inspection when you need to debug encoding issues directly.