Base64URL vs Base64 — The Difference That Breaks Your JWTs
Standard Base64 and Base64URL differ by exactly three character substitutions. Those three characters have broken more JWT validations, OAuth callbacks, and API integrations than almost any other encoding issue I've debugged.
The standard Base64 alphabet uses + and / as the 62nd and 63rd characters. Base64URL replaces them with - and _ respectively. It also drops = padding because those characters serve no purpose in a URL-safe context.
That's the entire difference — and the entire source of most "Invalid Base64" errors in web development.
The Three Changes
| Character Position | Standard Base64 | Base64URL | Why It Matters |
|---|---|---|---|
| 62nd character | + | - | + is treated as a space in URL query strings |
| 63rd character | / | _ | / breaks URL path routing |
| Padding | = | (removed) | = can confuse URL parameter parsing |
Side by Side
Input: "hello world??"
Standard: "aGVsbG8gd29ybGQ/Pw=="
Base64URL: "aGVsbG8gd29ybGQ_Pw"
Same input, two different outputs. If you decode one with the other's decoder, you get garbage or an error.
Why JWTs Use Base64URL
A JWT token travels through multiple transport layers:
https://auth.example.com/authorize?
response_type=code&
client_id=app-123&
redirect_uri=https://app.example.com/callback&
state=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ
The state parameter contains a Base64URL-encoded JSON object. If it used standard Base64, the + and / characters would corrupt the URL parsing on the receiving end. Base64URL makes it URL-safe by default, without requiring an additional URL-encoding pass.
Every JWT library implements Base64URL decoding for the header and payload segments. If you're decoding JWTs manually with a standard Base64 decoder, you'll get errors.
Converting Between the Two
Standard Base64 → Base64URL
function base64ToBase64URL(base64) {
return base64
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/, "");
}
const standard = "aGVsbG8/Pz8=";
const urlSafe = base64ToBase64URL(standard);
// "aGVsbG8_Pz8"
Base64URL → Standard Base64
function base64URLToBase64(base64url) {
// Restore + and /
let base64 = base64url
.replace(/-/g, "+")
.replace(/_/g, "/");
// Restore padding (length must be multiple of 4)
while (base64.length % 4 !== 0) {
base64 += "=";
}
return base64;
}
const urlSafe = "aGVsbG8_Pz8";
const standard = base64URLToBase64(urlSafe);
// "aGVsbG8/Pz8="
Python
import base64
# Standard Base64
standard = base64.b64encode(b"hello???")
print(standard) # b'aGVsbG8/Pz8='
# Base64URL (urlsafe_b64encode uses - and _)
urlsafe = base64.urlsafe_b64encode(b"hello???")
print(urlsafe) # b'aGVsbG8_Pz8='
# Decode either format
print(base64.urlsafe_b64decode("aGVsbG8_Pz8=")) # b'hello???'
print(base64.urlsafe_b64decode("aGVsbG8/Pz8=")) # b'hello???'
Note: Python's urlsafe_b64decode accepts both +// and -/_ characters, making it tolerant of the input format.
Java
import java.util.Base64;
// Standard
String standard = Base64.getEncoder().encodeToString("hello???".getBytes());
// "aGVsbG8/Pz8="
// URL-safe (without padding)
String urlSafe = Base64.getUrlEncoder().withoutPadding().encodeToString("hello???".getBytes());
// "aGVsbG8_Pz8"
// Decode (both formats work with getUrlDecoder)
byte[] decoded = Base64.getUrlDecoder().decode("aGVsbG8_Pz8");
System.out.println(new String(decoded)); // "hello???"
Java's Base64.getUrlDecoder() accepts both standard and Base64URL characters, which makes it forgiving for mixed input.
The Three Most Common Debugging Nightmares
1. JWT Rejected by the Identity Provider
You generate a JWT server-side. The token looks correct. But the IDP (Auth0, Okta, Keycloak) rejects it with a generic error.
// Server-side: using standard Base64 to build the JWT
const header = Buffer.from(JSON.stringify({ alg: "HS256" })).toString("base64");
const payload = Buffer.from(JSON.stringify({ sub: "123" })).toString("base64");
const token = `${header}.${payload}.${signature}`;
// The IDP can't parse this — header and payload use + and /,
// but JWT spec requires Base64URL
Fix: Use Buffer.toString("base64url") or manually convert + → -, / → _, and strip =.
2. OAuth State Parameter Mismatch
Your app generates a state value, Base64-encodes it, and sends it to the authorization endpoint. The callback receives a state parameter that looks similar but can't be decoded.
The + in your Base64 string got converted to a space by the URL parser. The server URL-decoded it (turning + into a space), but now your Base64 has a literal space character that atob() can't handle.
Fix: Use Base64URL for any data that travels through URLs.
3. Frontend JWT Decoding Produces Garbage
// User code: decode a JWT payload
const jwt = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjMifQ";
const payload = jwt.split(".")[1];
// Standard Base64 decode — this throws!
atob(payload); // InvalidCharacterError or wrong output
// Correct: restore padding first
const padded = payload.padEnd(payload.length + (4 - payload.length % 4) % 4, "=");
atob(padded
.replace(/-/g, "+")
.replace(/_/g, "/")
);
The missing = padding and the -/_ characters cause atob() to fail or produce incorrect output.
Language-Specific Support
Node.js (v15.7+)
// Built-in Base64URL support
const encoded = Buffer.from("hello???").toString("base64url");
// "aGVsbG8_Pz8"
const decoded = Buffer.from("aGVsbG8_Pz8", "base64url").toString("utf8");
// "hello???"
Go
import "encoding/base64"
// Standard
std := base64.StdEncoding.EncodeToString([]byte("hello???"))
// Base64URL (raw = no padding)
url := base64.RawURLEncoding.EncodeToString([]byte("hello???"))
// Decode handles both
decoded, _ := base64.RawURLEncoding.DecodeString("aGVsbG8_Pz8")
Go's RawURLEncoding is exactly what you want for JWT segments. URLEncoding (with padding) exists but isn't commonly used for JWTs.
Python
import base64
# For JWTs, use urlsafe_b64decode — it handles both formats
decoded = base64.urlsafe_b64decode(jwt_payload_segment)
# urlsafe_b64encode always produces Base64URL format
encoded = base64.urlsafe_b64encode(b"data")
When to Use Which
| Use Case | Encoding |
|---|---|
| JWT tokens (all segments) | Base64URL |
| OAuth state, nonce, redirect parameters | Base64URL |
| Data URIs in HTML | Standard Base64 |
| JSON API payloads with binary data | Standard Base64 |
| Kubernetes Secrets | Standard Base64 |
| MIME email attachments | Standard Base64 |
| URL query parameters | Base64URL (or URL-encode standard Base64) |
| HTTP cookies | Base64URL (cookies have their own character restrictions) |
FAQ
What's the difference between Base64 and Base64URL?
Three character substitutions: + → -, / → _, and = padding is removed. Base64URL is designed to be safe in URLs, cookies, and HTTP headers without additional encoding.
Why is my JWT getting rejected with "Invalid signature"?
The most likely cause is a Base64 vs Base64URL mismatch. If you encoded the header or payload with standard Base64, the signature was computed over the wrong input. Re-encode with Base64URL and re-sign.
Can I decode Base64URL with a standard Base64 decoder?
No, not without converting the characters first. Replace - with +, _ with /, and restore = padding to make the string a multiple of 4 characters.
Does Base64URL hurt security compared to standard Base64?
No. Both are encoding schemes — neither provides security. The character changes are purely for URL safety and have no cryptographic implications.
Do all JWT libraries handle Base64URL correctly?
Every well-known JWT library (jjwt, pyjwt, jsonwebtoken, go-jose) handles Base64URL internally. The problem only arises when you manually construct or decode JWT segments without using the library's API.
If you're debugging a JWT that won't decode, paste it into the Base64 Encoder & Decoder — it auto-detects Base64URL and restores padding automatically. For full JWT inspection including header parsing, payload display, and signature verification, use the JWT Decoder. Understanding the Base64 vs Base64URL distinction is also essential when working with URL encoding for OAuth and redirect flows.