[RFC] 8 min read

[RFC] Base64 URL-Safe vs Standard Base64

Standard Base64 and URL-safe Base64 look almost identical but break each other's decoders. Here is exactly what changes, why, and which variant you need for JWT, OAuth, and URLs.

April 2026 | standards

// TL;DR

Two Base64 alphabets exist, both specified in RFC 4648.

Standard Base64 (§4) — characters A–Z a–z 0–9 + /, with = padding. Safe for MIME, PEM files, HTTP Basic Auth, email. Breaks when dropped into URLs, cookies, filenames, or DNS labels.

URL-safe Base64, sometimes written base64url (§5) — characters A–Z a–z 0–9 - _, padding usually omitted. Safe for URLs, JWT parts, OAuth state tokens, cookies, DNS, filenames.

Decoders for one alphabet will reject the other's output. Pick the variant that matches your transport and stick to it.

// THE TWO ALPHABETS, SIDE BY SIDE

// Position 62 and 63 are the only differences
//
// Standard (RFC 4648 §4)   URL-safe (RFC 4648 §5)
// 0–25   A-Z                A-Z
// 26–51  a-z                a-z
// 52–61  0-9                0-9
// 62     +                  -      (hyphen)
// 63     /                  _      (underscore)
// pad    =                  = or omitted

// Worked example: encoding 0xFB 0xFF 0xBF
// binary: 11111011 11111111 10111111
// 6-bit:  111110 111111 111110 111111
// values: 62     63     62     63
// Standard:  + / + /       → '+/+/'
// URL-safe:  - _ - _       → '-_-_'

// WHY WE NEED A URL-SAFE VARIANT

The standard alphabet was designed for MIME bodies, which are permissive. URLs are not. In RFC 3986, + sometimes decodes to a space (inherited from HTML forms), / is a path separator, and = is a reserved sub-delimiter. If you drop a standard Base64 string with all three into a URL, different parsers will disagree about what it means. Percent-encoding fixes it technically (+%2B, /%2F, =%3D), but the result is bloated and ugly.

RFC 4648 §5 solves the problem by picking two ASCII characters that have no special meaning in URLs: - (hyphen) and _ (underscore). Both are unreserved per RFC 3986 and survive every URL parser intact. The padding = is also problematic in some URL contexts (it is the key/value separator in query strings), so base64url conventionally drops it.

// WHEN TO USE WHICH

  • > Standard Base64 — use for:
  • > • MIME email attachments (RFC 2045)
  • > • PEM-format certificates and keys (RFC 7468)
  • > • HTTP Basic Auth header (Authorization: Basic ...)
  • > • data: URIs (data:image/png;base64,...) — the +/= characters are all legal inside quoted attributes
  • > • XML-DSIG and XML-ENC signatures
  • > • S/MIME
  • > URL-safe Base64 — use for:
  • > • JWT headers, payloads, and signatures (RFC 7515 mandates base64url)
  • > • OAuth 2.0 code_challenge / PKCE (RFC 7636)
  • > • OpenID Connect state and nonce tokens
  • > • Magic links, invite codes, and short URL tokens
  • > • Cookie values that must survive being copied into a URL
  • > • Filenames generated from hashes (avoid / in filenames!)
  • > • DNS labels and TXT records (dash is allowed, slash is not)
  • > • Content-addressable storage keys (IPFS-like systems)

// CONVERTING BETWEEN THE TWO

Since the two alphabets only differ at positions 62, 63, and the padding, conversion is a three-character find-and-replace. Here it is in every runtime you are likely to use.

// JavaScript (browser + Node)
const toUrlSafe = (b64) =>
  b64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');

const fromUrlSafe = (b64u) => {
  let s = b64u.replace(/-/g, '+').replace(/_/g, '/');
  while (s.length % 4) s += '=';   // re-add padding
  return s;
};

// Node 16+ native
const url = Buffer.from(data).toString('base64url');
const std = Buffer.from(url, 'base64url').toString('base64');

// Python
import base64
url = base64.urlsafe_b64encode(data).rstrip(b'=')      # no padding
std = base64.b64encode(data)

// Go
import "encoding/base64"
url := base64.RawURLEncoding.EncodeToString(data)  // no padding
std := base64.StdEncoding.EncodeToString(data)

// PHP
$url = rtrim(strtr(base64_encode($data), '+/', '-_'), '=');
$std = base64_encode($data);

// Java
String url = Base64.getUrlEncoder().withoutPadding().encodeToString(data);
String std = Base64.getEncoder().encodeToString(data);

// Shell (GNU coreutils 9+)
echo -n 'hello' | base64 --wrap=0 | tr '+/' '-_' | tr -d '='

// THE PADDING QUESTION

Standard Base64 always pads the output to a multiple of 4 characters with =. URL-safe Base64 usually omits padding (RFC 4648 §5 explicitly calls this out as optional). JWT (RFC 7515) forbids padding entirely — eyJhbGciOiJIUzI1NiJ9 has no trailing =. OAuth code challenges (RFC 7636) also forbid padding.

Decoders must handle both. A permissive decoder strips any trailing = and pads internally if the payload length is not a multiple of 4. A strict decoder rejects anything that is not already 4-aligned. If you are writing encoders, match the receiver's expectation. If you are writing decoders, be generous.

How much padding to add when decoding an unpadded base64url? The rule is: if the input length modulo 4 equals 2, add two =; if it equals 3, add one =; if it equals 0, add nothing; if it equals 1, the input is invalid (no valid base64 has that length mod 4).

// Restore padding before decoding a JWT segment
function addPadding(b64u) {
  const mod = b64u.length % 4;
  if (mod === 2) return b64u + '==';
  if (mod === 3) return b64u + '=';
  if (mod === 0) return b64u;
  throw new Error('Invalid base64url length');
}

// WHAT BREAKS IF YOU MIX THEM

  • > Silent data corruption — a standard decoder fed a URL-safe payload will decode most characters correctly, but - and _ are not in its alphabet. Depending on the implementation, it may throw, it may treat them as junk (skipping them), or it may silently produce wrong bytes.
  • > Length mismatches — the decoder sees a string whose length is not a multiple of 4 (because padding was dropped), and rejects it outright. Frequent pain point with JWT libraries that expect padded input.
  • > Percent-encoding surprises — if you accidentally put standard Base64 into a URL and a middleware percent-encodes only + and =, you now have a payload with mixed encoding: valid for some decoders, garbage for others.
  • > Cookie rejections — several HTTP servers reject cookies that contain / or = in the value, even though the spec technically allows it. URL-safe Base64 sidesteps this entirely.
  • > Filename failures on Windows — Base64 standard output contains /, which is a path separator. Naming a file after its SHA-256 in standard Base64 fails on Windows and POSIX both.
  • > Copy-paste corruption — some terminals and chat apps word-wrap on /, breaking a standard Base64 string across lines invisibly. URL-safe does not trigger this.

// REAL-WORLD CASE STUDY: JWT

A JSON Web Token looks like header.payload.signature, three base64url strings separated by dots. Every major JWT library (jose, jsonwebtoken, PyJWT, golang-jwt, java-jwt) encodes with base64url without padding, because the tokens are routinely placed in URLs and HTTP headers.

If you decode a JWT by hand with atob() in the browser, you must first convert: replace - with +, _ with /, and add any missing =. Libraries like jose handle this internally.

// Decode a JWT payload in the browser
function decodeJwt(token) {
  const [, payloadB64u] = token.split('.');
  let b64 = payloadB64u.replace(/-/g, '+').replace(/_/g, '/');
  while (b64.length % 4) b64 += '=';
  const json = atob(b64);
  return JSON.parse(json);
}

// Example JWT payload
decodeJwt('eyJhbGciOiJIUzI1NiJ9' +
          '.eyJzdWIiOiJhYmMxMjMifQ' +
          '.sig').sub;     // 'abc123'

// DATA URIs: AN EDGE CASE

Data URIs (data:image/png;base64,...) are technically URLs, but per RFC 2397 they accept the full standard Base64 alphabet including + and /. That is why you see standard Base64 in data URIs everywhere — it is the spec.

URL-safe Base64 inside a data URI technically violates RFC 2397 and may be rejected by strict parsers. Stick to standard Base64 for data URIs. The only time URL-safe matters for data URIs is if the URI itself gets embedded in another URL as a query parameter — at which point you should percent-encode the whole thing instead.

// BASE64URL IS NOT URL-ENCODING

Worth saying explicitly, because the names are confusingly similar. URL encoding (also called percent-encoding, RFC 3986) transforms reserved characters into %XX sequences — it's what happens to a space when you type it into a browser address bar (%20).

URL-safe Base64 is a flavour of Base64 designed to not need URL-encoding. It produces an output that is already URL-safe, so no percent-encoding is required. You never apply both in sequence; you apply one or the other depending on your data.

Rule of thumb: if the data is binary (an image, a hash, a signature) and you need to put it in a URL, use base64url. If the data is already text (a query parameter value, a form field), use percent-encoding.

// DECISION TREE

  • > Is the Base64 string going into a URL, a cookie value, a filename, a DNS label, a JWT, or an OAuth flow? → URL-safe Base64.
  • > Is it going into an email body, a PEM file, an HTTP Basic Auth header, a data URI, or a MIME multipart body? → Standard Base64.
  • > Is the consumer a library you don't control (JWT parser, OAuth provider, S/MIME client)? → Match what they expect; read the spec or the library docs.
  • > Not sure? → Use URL-safe without padding. It's the most portable variant; you can always pad it out for strict receivers.

// TRY IT YOURSELF

Our Base64 encoder outputs standard Base64 by default with a toggle for URL-safe output. Our base64url encoder defaults to URL-safe without padding. Use them side by side to see exactly which characters change.

Further reading:
What is Base64 and how does it work? — the 6-bit grouping that underlies both variants.
Base64 in JavaScript (browser + Node) — runtime-specific helpers.
Base64 vs Base64URL vs Base32 — when none of the Base64 variants is the right choice.