DEV Community

Rasika Dangamuwa
Rasika Dangamuwa

Posted on

Why Base64 Decoding a JWT Isn't Enough: 5 Security Edge Cases Every Developer Misses

In modern microservice architectures and single-page applications, JSON Web Tokens (JWTs) are the de facto standard for stateless authorization. Because a JWT looks like a simple dot-separated string containing Base64-encoded JSON, developers frequently write quick inline decoders or naive middleware to extract user IDs, scopes, or expiration timestamps.

That simplicity is deceptive. Inspecting claims without understanding the underlying RFC 7519 spec and cryptographic edge cases leads to silent authentication failures, clock drift bugs, or severe authorization bypass vulnerabilities. Here are 5 critical edge cases every developer should know when working with JWTs.

1. Base64 vs. Base64URL Encoding & Missing Padding

JWTs do not use standard Base64 (RFC 4648 ยง4); they use Base64URL (RFC 4648 ยง5). Standard Base64 uses + and / characters and relies on = padding bytes. Base64URL replaces + with - and / with _, and explicitly omits trailing = padding.

If your custom decoder uses standard atob() or Buffer.from(str, 'base64') without sanitizing input:

  • Strings with length not divisible by 4 will fail or throw Invalid Character errors.
  • Characters like - or _ will corrupt the decoded JSON string.

To safely decode Base64URL in JavaScript:

function decodeBase64URL(str) {
  let base64 = str.replace(/-/g, '+').replace(/_/g, '/');
  while (base64.length % 4) {
    base64 += '=';
  }
  return atob(base64);
}
Enter fullscreen mode Exit fullscreen mode

2. The alg: "none" Signature Bypass

A JWT header contains metadata specifying how the token was created:

{
  "alg": "none",
  "typ": "JWT"
}
Enter fullscreen mode Exit fullscreen mode

RFC 7519 allows an algorithm of "none" for unsigned tokens. In early implementations of popular JWT libraries, attackers could take a valid token, modify the payload (e.g., changing "role": "user" to "role": "admin"), change alg to "none", and strip the signature portion entirely. Naive verification functions that trusted the header's alg parameter would skip signature verification and accept the token as valid.

Server-side verification logic must never trust the algorithm defined inside the incoming token header. Always specify an explicit whitelist of allowed algorithms in your verification options: jwt.verify(token, secret, { algorithms: ['HS256'] }).

3. Timestamp Units & Clock Skew

Standard claims like exp (expiration), iat (issued at), and nbf (not before) are defined as NumericDate values โ€” Unix timestamps in seconds since January 1, 1970 UTC.

A common bug occurs when developers compare payload.exp against JavaScript's Date.now(), which returns milliseconds:

// BUG: exp is in seconds (e.g. 1700000000), Date.now() is in ms (1700000000000)
if (payload.exp < Date.now()) {
  // Token appears expired 50 years into the future!
}
Enter fullscreen mode Exit fullscreen mode

Always convert Date.now() to seconds (Math.floor(Date.now() / 1000)) before comparing.

Additionally, distributed servers suffer from minor clock drift. If an auth server issues a token at 12:00:05 and sends it to an API server whose system clock is set to 12:00:00, the API server will reject the token because nbf or iat is in the future. Always configure a clock tolerance (e.g. 5 seconds) in your JWT verification middleware.

4. Algorithm Confusion (RS256 vs. HS256)

When an authentication server uses RS256 (asymmetric RSA signature), it signs tokens with a private key and publishes a public key for API services to verify signatures.

If the verification middleware accepts whatever algorithm is declared in the token header, an attacker can exploit algorithm confusion:

  1. The attacker obtains the public key (which is publicly accessible).
  2. The attacker crafts a forged payload and changes the header algorithm to HS256 (symmetric HMAC).
  3. The attacker signs the token using the public RSA key as the HMAC secret string!

Because HS256 uses a shared secret, when the server calls jwt.verify(token, publicKey), the library sees alg: HS256 in the header, uses publicKey as a raw secret, and verifies the signature successfully.

5. Non-Standard Claims and Type Mismatches

RFC 7519 allows claims like aud (audience) to be either a single string or an array of strings:

{
  "aud": ["https://api.example.com", "https://billing.example.com"]
}
Enter fullscreen mode Exit fullscreen mode

If your validation code assumes typeof payload.aud === 'string', inspecting complex tokens will throw unhandled runtime exceptions.

When inspecting token payloads during local API integration or debugging authorization header issues, manually splitting tokens and decoding Base64 strings in terminal commands can be error-prone. Using a dedicated browser-based utility like the Nutilz JWT Decoder allows you to instantly inspect header parameters, claims, and expiration timestamps locally without exposing sensitive production keys or sending network calls.

Summary

Stateless tokens eliminate database lookups for session management, but their security depends entirely on proper parsing and strict validation. Never trust the alg header parameter, handle Base64URL padding quirks explicitly, normalize Unix timestamps to seconds, and guard against algorithm confusion attacks. For quick visual inspection of payloads and claims during development, use the Nutilz JWT Decoder to debug tokens safely in your browser.

Top comments (0)