JWT Uses Base64URL, Not Standard Base64: Decoding JWT Parts Correctly

Quick answer

๐Ÿ’กJWT parts are encoded with base64url (RFC 4648 ยง5), not standard base64. Base64url replaces + with -, / with _, and omits = padding. atob() rejects - and _ characters, throwing InvalidCharacterError. In Node.js 16+, decode with Buffer.from(part, 'base64url'). In browsers, restore the standard alphabet first: replace - with +, _ with /, restore = padding, then call atob().

Error symptoms

  • โœ•InvalidCharacterError: Failed to execute 'atob' on 'Window': The string to be decoded contains characters outside of the Latin1 range
  • โœ•DOMException: atob() โ€” string contains invalid base64 characters
  • โœ•Buffer.from(jwtPart, 'base64') decodes to wrong bytes due to dropped - and _ characters
  • โœ•JSON.parse throws SyntaxError after base64 decoding a JWT payload โ€” partial or incorrect bytes
  • โœ•JWT header shows as garbled binary after decoding with standard base64 Buffer encoding
  • โœ•Padding error: base64 decode fails because JWT parts do not have = padding

Common causes

  • โ€ขUsing atob() directly on JWT parts without converting - back to + and _ back to /
  • โ€ขUsing Buffer.from(part, 'base64') in Node.js instead of Buffer.from(part, 'base64url') for JWT parts
  • โ€ขNot restoring = padding before passing a JWT part to a standard base64 decoder
  • โ€ขSplitting JWT on '.' but including the 'Bearer ' prefix from the Authorization header
  • โ€ขBase64url-encoded JWT part decoded as UTF-16 or Latin1 instead of UTF-8, producing garbled JSON
  • โ€ขConfusing the JWT signature (third part) with the header and payload โ€” the signature is binary, not JSON

When it happens

  • โ€ขWhen inspecting a JWT header or payload in browser JavaScript for debugging or display
  • โ€ขWhen implementing a middleware that reads JWT claims without a full JWT library
  • โ€ขWhen writing a JWT decoder tool or logging utility that needs to display JWT contents
  • โ€ขWhen porting JWT decoding code from a library (jsonwebtoken) to manual decoding
  • โ€ขWhen writing a service-to-service authentication handler that parses JWT parts manually

Examples and fixes

Use Buffer.from(part, 'base64url') for JWT parts in Node.js 16 and later. For older versions, convert the alphabet manually.

Decoding a JWT payload in Node.js 16+

โŒ Wrong

// Wrong: standard base64 encoding, drops - and _ characters
function decodeJwtPayload(token) {
  const parts = token.split('.');
  const payload = parts[1]; // e.g. 'eyJzdWIiOiIxMjM0In0'
  // Buffer.from with 'base64' ignores - and _ silently
  const decoded = Buffer.from(payload, 'base64').toString('utf8');
  return JSON.parse(decoded); // may fail or return wrong data
}
// atob(payload) in browser: throws InvalidCharacterError
// if payload contains - or _ characters

โœ… Fixed

// Node.js 16+: use 'base64url' encoding directly
function decodeJwtPayload(token) {
  const parts = token.split('.');
  if (parts.length !== 3) throw new Error('Invalid JWT format');
  const payload = parts[1]; // base64url-encoded
  const json = Buffer.from(payload, 'base64url').toString('utf8');
  return JSON.parse(json);
}
// Node.js 14 and earlier, or browser:
function decodeJwtPayloadCompat(token) {
  const parts = token.split('.');
  let base64 = parts[1].replace(/-/g, '+').replace(/_/g, '/');
  // Restore padding to make length a multiple of 4:
  base64 += '=='.slice(0, (4 - base64.length % 4) % 4);
  const json = atob(base64); // browser, or Buffer.from(base64,'base64')
  return JSON.parse(json);
}

JWT uses base64url (RFC 4648 ยง5) for all three parts. Base64url substitutes + with - and / with _, and omits the = padding characters. Standard base64 decoders have two problems with JWT parts: (1) atob() rejects - and _ as invalid base64 characters and throws InvalidCharacterError, and (2) Buffer.from(str, 'base64') silently ignores invalid characters including - and _, producing a shorter buffer that decodes to wrong bytes without any error. Node.js 16 added 'base64url' as a first-class Buffer encoding that handles the character substitution and padding restoration automatically. For older environments, the character substitution must be done manually with two replace() calls, followed by padding restoration: adding 0, 1, or 2 = characters to make the length a multiple of 4.

Browser-compatible JWT payload decoding using atob() with alphabet conversion and UTF-8 decoding.

Decoding a JWT payload in the browser without a library

โŒ Wrong

// Broken: atob() rejects base64url alphabet
function parseJwtClaims(token) {
  const payload = token.split('.')[1];
  // Throws: InvalidCharacterError if - or _ present
  const decoded = atob(payload);
  return JSON.parse(decoded);
  // Also broken: atob returns Latin1 string, not UTF-8
  // Non-ASCII characters in claims will be garbled
}

โœ… Fixed

// Correct browser decoding with UTF-8 support
function parseJwtClaims(token) {
  const parts = token.split('.');
  if (parts.length !== 3) throw new Error('Not a JWT');
  // Convert base64url to standard base64:
  let b64 = parts[1].replace(/-/g, '+').replace(/_/g, '/');
  // Restore missing padding:
  b64 += '=='.slice(0, (4 - b64.length % 4) % 4);
  // Decode base64 to a byte string and re-decode as UTF-8:
  const bytes = Uint8Array.from(atob(b64), c => c.charCodeAt(0));
  const json = new TextDecoder().decode(bytes);
  return JSON.parse(json);
}

Two problems exist with the naive approach. First, atob() rejects base64url characters (- and _), so the alphabet must be converted to standard base64 first. Second, atob() returns a Latin1 string โ€” each character's code point equals the original byte value. For ASCII-only JSON this is fine, but JWT payloads can contain non-ASCII characters in claims like name, email, or address fields encoded as UTF-8 multi-byte sequences. Calling JSON.parse() on the Latin1 string will produce garbled or incorrect values for these characters. The fix re-interprets the atob() output as raw bytes using Uint8Array.from(), then decodes those bytes as UTF-8 with TextDecoder, producing a correct Unicode string regardless of the character encoding in the original payload.

Why JWT uses base64url instead of standard base64

JWT (JSON Web Token) is defined in RFC 7519, which specifies that the header, payload, and signature are each encoded with base64url (RFC 4648 ยง5), not standard base64. The reason is practical: JWTs are designed to be transmitted in URLs, HTTP headers, and query parameters. Standard base64 uses + and / as alphabet characters. In URLs, + is interpreted as a space character (in application/x-www-form-urlencoded encoding), and / is interpreted as a path separator. Using standard base64 in a URL would require additional percent-encoding of those characters, making the token longer and more awkward.

Base64url solves this by substituting two characters: + becomes - and / becomes _. Both - and _ are safe in all URL contexts without percent-encoding. Base64url also omits the = padding character that standard base64 uses to pad output to a multiple of 4 characters. In a JWT, the three parts are separated by periods, so padding would need to be removed before transmission anyway to avoid ambiguity.

The structure of a JWT is header.payload.signature, where each part is independently base64url-encoded. The header and payload are UTF-8 encoded JSON objects, so decoding them yields human-readable JSON. The signature is the HMAC-SHA-256 or RSA-SHA-256 binary digest of the header.payload string, so decoding the signature part yields binary bytes, not JSON. Never attempt to JSON.parse() the third part of a JWT.

This distinction between base64url and standard base64 is the root cause of almost all "atob fails on JWT" bugs. Developers copy a JWT from an Authorization header or localStorage, split on '.', and pass a part directly to atob() or Buffer.from(part, 'base64'). If the part happens to not contain - or _ (many short payloads do not), the code works. But JWTs with longer payloads, especially those with many claims or non-ASCII content, almost certainly contain - or _ in their base64url encoding, causing silent data corruption or an exception.

Diagnosing JWT base64url decoding failures

When atob() throws InvalidCharacterError on a JWT part, examine the JWT part string for - or _ characters. The presence of either confirms it is base64url encoded. Count the characters: standard base64 length is always a multiple of 4 (including = padding). Base64url omits padding, so the length may not be a multiple of 4. The number of missing padding characters is: (4 - length % 4) % 4.

When Buffer.from(part, 'base64') in Node.js produces wrong JSON (SyntaxError in JSON.parse, or unexpected character values in decoded strings), the issue is likely that - or _ in the JWT part are being silently dropped. Node.js's standard 'base64' encoding ignores characters outside the base64 alphabet rather than throwing. This silent failure makes the bug hard to detect because no error is thrown โ€” the code appears to work but produces wrong output.

To quickly identify whether a base64url string's decode failure is due to missing padding or alphabet differences, manually inspect the string length modulo 4. If length % 4 == 1, the string is invalid (a valid base64url-encoded value will never have length % 4 == 1 because the original 3-byte groups produce output lengths of 4, 8, 12, ... or with 1 leftover byte: 2 base64url chars, with 2 leftover bytes: 3 base64url chars). If you encounter a JWT part with length % 4 == 1, the JWT may be malformed or truncated.

For debugging JWT payload content in the browser, open browser devtools console and paste this one-liner: JSON.parse(new TextDecoder().decode(Uint8Array.from(atob(token.split('.')[1].replace(/-/g,'+').replace(/_/g,'/')+'=='.slice(0,(4-token.split('.')[1].length%4)%4)),c=>c.charCodeAt(0)))). This is a useful diagnostic expression for any JWT token.

Correct JWT decoding across environments

In Node.js 16 and later, use the 'base64url' encoding with Buffer.from(): Buffer.from(jwtPart, 'base64url').toString('utf8'). This is the cleanest solution โ€” the 'base64url' encoding handles the character substitution and padding restoration internally. For JSON payloads: JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8')). This works correctly for all valid JWTs, including those with and without - or _ characters in the part.

In Node.js 14 and earlier, or in any environment without native 'base64url' Buffer support, use the two-step approach: (1) convert the alphabet with .replace(/-/g, '+').replace(/_/g, '/'), and (2) restore padding with base64Str += '=='.slice(0, (4 - base64Str.length % 4) % 4). The expression (4 - len % 4) % 4 correctly computes 0, 1, or 2 padding characters: 0 when no padding is needed (len % 4 == 0), 2 when 2 chars are needed (len % 4 == 2), 1 when 1 char is needed (len % 4 == 3). The case len % 4 == 1 is invalid and should not occur in a well-formed JWT.

In browsers, after the alphabet conversion and padding restoration, use atob() to decode to a Latin1 byte string. If the payload contains only ASCII characters (common for simple JWTs with string claims), JSON.parse(atob(base64Str)) works correctly. For UTF-8 safety (always recommended), convert the atob() output to a Uint8Array and decode with TextDecoder: new TextDecoder().decode(Uint8Array.from(atob(b64), c => c.charCodeAt(0))). This correctly handles non-ASCII characters in JWT claims.

For production code that needs to validate JWTs (not just decode them), use a proper JWT library: jsonwebtoken in Node.js, jose (works in Node.js, browsers, and Deno), or the jwt-decode npm package for decode-only operations. These libraries handle the base64url conversion, signature verification, and claims validation (exp, iss, aud) correctly and are maintained against known vulnerabilities.

JWT padding, signature decoding, and the Bearer prefix

The JWT signature (the third part after the second period) is binary data โ€” the HMAC-SHA-256 digest or RSA signature bytes. Decoding it with base64url gives you raw bytes, not JSON. A HMAC-SHA-256 signature is 32 bytes; an RS256 signature is 256 bytes (2048-bit RSA) or 512 bytes (4096-bit RSA). Never attempt to JSON.parse() the third part of a JWT. If you want to inspect the signature, decode it to a Buffer or Uint8Array and display it as hex: Buffer.from(parts[2], 'base64url').toString('hex').

The Bearer prefix in Authorization headers is a frequent source of splitting bugs. The Authorization header value is 'Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0In0.abc'. When you split by '.', you get ['Bearer eyJhbGciOiJIUzI1NiJ9', 'eyJzdWIiOiIxMjM0In0', 'abc'] โ€” the first part includes 'Bearer ' which is not valid base64url. Always strip the 'Bearer ' prefix before splitting: const token = authHeader.replace(/^Bearer /, ''); const parts = token.split('.').

Base64url-encoded JWT parts should never contain whitespace, but some systems add newlines when logging long tokens. If you are reading a JWT from a log file or a copy-pasted string, strip whitespace before processing: token.trim().replace(/\s/g, ''). A single newline character in the middle of a JWT part will cause all base64 decoders to produce incorrect output.

JWT RS256 and ES256 signatures have different binary structures. RS256 uses PKCS#1 RSASSA-PKCS1-v1_5 (a fixed-length signature). ES256 uses ECDSA with P-256 in the JWT-specific DER-to-raw conversion format (two 32-byte integers R and S concatenated, not the ASN.1 DER encoding that OpenSSL produces). If you are verifying an ES256 JWT signature with OpenSSL, you must convert the JWT's 64-byte raw signature to DER format first. The jose library handles this automatically.

Common JWT decoding mistakes in production code

Trusting an unverified JWT for authorization decisions is the most critical mistake. Decoding a JWT header and payload is trivial โ€” anyone can create a JWT with any claims without a signature. The signature must be cryptographically verified against the expected secret (HMAC) or public key (RSA, ECDSA) before trusting any claim in the payload. Decoding-only libraries like jwt-decode explicitly do not verify signatures and should never be used for authorization.

Caching the JWT public key verification result incorrectly leads to authentication bypass. If you cache whether a JWT was valid without storing the specific claims, an attacker can replace a valid JWT with a different valid JWT for a different user but reuse the cached validation result. Cache the verified claims object (subject, roles, expiry), not a boolean validity flag.

Ignoring the algorithm field in the JWT header enables algorithm confusion attacks. Some early JWT libraries accepted an alg: 'none' header indicating no signature was required, allowing attackers to forge tokens. Current JOSE libraries default to rejecting 'none'. However, if you use an RS256 key to verify and an attacker creates an HS256 token where the HMAC secret is the RSA public key (which is publicly available), naive libraries that accept both algorithms with the same key will accept the forged token. Always specify the expected algorithm explicitly in your verification call: jwt.verify(token, publicKey, {algorithms: ['RS256']}).

Not checking JWT expiration in microservices is a common oversight. If service A receives and caches a JWT, and the token expires 5 minutes later, service A may continue accepting the cached token without re-verifying expiry. Always check the exp claim in the payload even for cached tokens: if (payload.exp < Date.now() / 1000) throw new Error('Token expired'). The jwt.verify() function checks expiry automatically, but manual claim extraction without a library does not.

JWT base64url decoding best practices

For production code that decodes JWTs, use the jose library (available on npm, Deno, and with browser support) or jsonwebtoken for Node.js. These libraries are actively maintained, handle base64url conversion transparently, verify signatures, validate all standard claims (exp, iat, nbf, iss, aud), and are tested against known attack patterns. Implement JWT decoding yourself only for educational purposes or in environments where dependencies are strictly limited.

When you must decode JWT parts manually (debugging, logging, lightweight tooling), encapsulate the base64url decode logic in a single utility function and use it everywhere. A production-quality utility function should: validate the JWT has exactly three parts, use 'base64url' Buffer encoding in Node.js 16+ (falling back to manual replacement in older versions), decode to UTF-8 with TextDecoder in browsers, catch and re-throw JSON.parse errors with context about which JWT part failed, and never attempt to parse the third (signature) part as JSON.

For logging and debugging JWTs in production, log only the payload claims that are safe to expose โ€” avoid logging the full token or the signature. The JWT itself is a credential: anyone with the token can use it until it expires. Log the sub (subject), iat (issued at), exp (expiry), and any role claims, but redact or truncate the full token string. Use a structured logging library that lets you define a log redaction policy for fields that match jwt or authorization.

For JWT inspection in development environments, use the ToolDock JWT Decoder tool to paste a token and immediately see the decoded header and payload with proper base64url decoding, algorithm identification, and expiry display. This is faster than writing debug code and eliminates the manual base64url conversion steps entirely.

Quick fix checklist

  • โœ“Use Buffer.from(part, 'base64url') in Node.js 16+ instead of the 'base64' encoding
  • โœ“Convert - to + and _ to / before passing JWT parts to atob() or older base64 decoders
  • โœ“Restore = padding: add (4 - len % 4) % 4 equals signs before standard base64 decoding
  • โœ“Strip 'Bearer ' prefix from Authorization header before splitting on '.'
  • โœ“Use TextDecoder for browser decoding to correctly handle non-ASCII UTF-8 characters in claims
  • โœ“Never JSON.parse the third JWT part (signature) โ€” it is binary bytes, not JSON
  • โœ“Always verify JWT signatures before trusting claims โ€” decoding is not verification
  • โœ“Specify expected algorithms explicitly in jwt.verify() to prevent algorithm confusion attacks

Related guides

Frequently asked questions

Why does atob() throw on JWT parts?

JWT parts use base64url encoding, which replaces + with - and / with _. atob() only accepts the standard base64 alphabet (A-Za-z0-9+/=) and throws InvalidCharacterError when it encounters - or _. Fix by converting - to + and _ to / before calling atob(), then restore = padding to make the string length a multiple of 4.

What is the difference between base64 and base64url?

Standard base64 (RFC 4648 ยง4) uses characters A-Za-z0-9+/ and = for padding. Base64url (RFC 4648 ยง5) uses A-Za-z0-9-_ and omits = padding. The substitutions make base64url safe for URLs, HTTP headers, JSON field names, and filenames without percent-encoding. JWTs use base64url for all three parts. Node.js 14+ supports 'base64url' as a Buffer encoding.

How do I add padding back to a base64url string?

Add (4 - str.length % 4) % 4 equals signs to the end of the base64url string before decoding with a standard base64 decoder. This expression produces 0 when no padding is needed, 1 or 2 when padding is required. In code: str += '=='.slice(0, (4 - str.length % 4) % 4). The case str.length % 4 == 1 should never occur in a valid base64url string.

Can I decode a JWT without a library?

Yes, for display or debugging purposes. In Node.js 16+: JSON.parse(Buffer.from(token.split('.')[1], 'base64url').toString('utf8')). In browsers: JSON.parse(new TextDecoder().decode(Uint8Array.from(atob(part.replace(/-/g,'+').replace(/_/g,'/')+'=='.slice(0,(4-part.length%4)%4)),c=>c.charCodeAt(0)))). For authorization, always verify the signature with a proper library โ€” decoding is not the same as verification.

Why is the JWT third part (signature) not valid JSON?

The JWT signature is the raw binary output of HMAC-SHA-256 (32 bytes) or an RSA/ECDSA operation. It is base64url-encoded for transport but decodes to arbitrary bytes, not a UTF-8 JSON string. Attempting to JSON.parse() the decoded signature will throw a SyntaxError. To inspect the signature, decode it to hex: Buffer.from(parts[2], 'base64url').toString('hex') gives you the 64-character hex representation of the HMAC-SHA-256 signature.

Does Buffer.from(str, 'base64') work for JWT parts?

Not correctly. Buffer.from(str, 'base64') silently ignores characters outside the standard base64 alphabet, including - and _ from base64url. This does not throw an error, but it produces a shorter buffer with different bytes than the original JWT part. If the JWT part does not contain any - or _ (which is common for very short parts), the result happens to be correct, masking the bug. Always use Buffer.from(str, 'base64url') for JWT parts in Node.js 16+.

How do I decode a JWT in a browser without npm packages?

Use this function: function decodeJwt(token) { const part = token.split('.')[1]; let b64 = part.replace(/-/g,'+').replace(/_/g,'/'); b64 += '=='.slice(0,(4-b64.length%4)%4); return JSON.parse(new TextDecoder().decode(Uint8Array.from(atob(b64),c=>c.charCodeAt(0)))); }. This handles base64url conversion, padding restoration, and UTF-8 decoding correctly in all modern browsers.

What Node.js version added native base64url support?

Node.js 14.18.0 (LTS) added 'base64url' as a valid encoding for Buffer.from() and buffer.toString(). Before this, base64url was not supported natively and required manual character replacement. Node.js 16 and all later LTS versions fully support 'base64url'. For Node.js 12 and earlier (now end-of-life), use the manual replacement approach: replace - with +, _ with /, restore = padding, then use 'base64'.

All tools run in your browser. Your data never leaves your device. Last updated: 2026-05-06.