iKit
Tutorial · 11 min read ·

How to Decode a JWT in 2026 — Auth0 & Firebase Examples

Decode a JWT in 2026 with real Auth0 and Firebase tokens — header, payload, signature explained, common debugging traps, and why pasting tokens online is risky.

How to Decode a JWT in 2026 — Auth0 & Firebase Examples

How to Decode a JWT in 2026 — Auth0 & Firebase Examples

A Cloud Run instance returns 401 Unauthorized and you have ten minutes before standup. The bearer token is right there in the network tab — three base64 chunks separated by dots — and the answer is almost certainly inside it. Knowing how to crack a JWT open in your browser, read the claims, and spot the bad field is one of the highest-leverage debugging skills of the 2020s. This guide walks through real Auth0 and Firebase tokens, the part that trips most people up, and how to do it without leaking your secret in a server tool.

TL;DR

  • A JWT is three base64url-encoded parts joined by dots: header, payload, signature.
  • Decoding is free and public — only the signature proves anything about trust.
  • Auth0 and Firebase tokens follow the same RFC 7519 shape but use different issuer URLs.
  • exp is the field that fails most often in production — always check expiry first.
  • Decode locally in your browser with the iKit JWT Decoder — the token never leaves your machine.

What a JWT Actually Is

A JSON Web Token is a string of three parts joined by dots. Each part is base64url-encoded — the URL-safe variant of base64 that swaps +/ for -_ and strips trailing = padding. Decode the first two parts and you get plain JSON; the third part is a signature in raw bytes. The whole token is designed to ride inside an Authorization: Bearer ... header without further escaping.

The three parts in order

The structure is the same for every JWT ever issued, whether from Auth0, Firebase, Okta, Cognito, your homegrown Laravel app, or a 2016 Spring Boot service still running in a container somewhere. Three parts, two dots, never more:

<base64url(header)>.<base64url(payload)>.<base64url(signature)>

The header advertises which signing algorithm was used (typically RS256 or HS256) and which key signed it. The payload carries the claims — sub for user ID, iat for issued-at, exp for expiry, and whatever custom fields the issuer chose to embed. The signature is the cryptographic proof that the header and payload were not modified after the issuer signed them.

Why base64url and not standard base64

Standard base64 uses + and /, two characters that mean something else in a URL — + becomes a space in form encoding, and / separates path segments. JWTs are often passed in query strings or HTTP headers, so RFC 7515 defines base64url to make them safe in those positions. The fix is small: swap +-, /_, strip the = padding. For a 12-minute primer on the underlying encoding, see our base64 deep-dive.

The signature is what matters for trust

The first two parts are not encrypted — anyone with the token can read them. The reason a JWT is useful for auth at all is the third part: a signature computed by the issuer over the header and payload using a secret (HS256) or a private key (RS256). Modifying any byte of the first two parts invalidates the signature, and the recipient's verification step will throw. Decoding is free; trusting requires verifying.

Decoding a Real Auth0 Token

Here's an Auth0-issued access token captured from a working SPA login. The signature is truncated for brevity. Every Auth0 tenant produces tokens in this shape — the field names are stable across the platform.

The token, broken into pieces

eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIs
ImtpZCI6Ik5HX2c0WHJzd1JIVkhYTGttdkpw
ZyJ9.eyJpc3MiOiJodHRwczovL2lraXQt
ZGV2LnVzLmF1dGgwLmNvbS8iLCJzdWIiOi
JhdXRoMHw2NjU0...

Split on the dots and base64url-decode the first chunk. The header reveals two useful fields: alg confirms RS256, and kid (key ID) tells you which of Auth0's published public keys was used to sign. Both fields matter when you reach the verification step.

{
  "alg": "RS256",
  "typ": "JWT",
  "kid": "NG_g4XrswRHVHXLkmvJpg"
}

Reading the Auth0 payload

Decode the second chunk and you get the meat of the token. Auth0 embeds the standard RFC 7519 claims plus a handful of platform-specific ones:

{
  "iss": "https://ikit-dev.us.auth0.com/",
  "sub": "auth0|66541f...",
  "aud": [
    "https://api.ikit.app",
    "https://ikit-dev.us.auth0.com/userinfo"
  ],
  "iat": 1746950400,
  "exp": 1747036800,
  "scope": "openid profile email",
  "permissions": ["read:tools", "write:profile"]
}

The iss field is the most useful one for fingerprinting an Auth0 token — https://TENANT.REGION.auth0.com/ is a dead giveaway. The aud array is what your API checks to confirm the token was issued for it and not some other Auth0 API in the same tenant. permissions is Auth0's per-API role system, not part of RFC 7519 but widely supported by middleware.

The expiry timestamps

iat and exp are Unix timestamps in seconds — not milliseconds. A common mistake is comparing them with Date.now() directly, which returns milliseconds and is therefore ~1000× larger. The token above was issued at 1746950400 (2026-05-11 08:00 UTC) and expires 24 hours later at 1747036800. If your decoder shows an exp that's already in the past, that's almost always the bug.

Decoding a Real Firebase Token

Firebase Authentication issues ID tokens in the same JWT shape but with a Google-specific payload. The differences matter when you're writing middleware that needs to handle both, so it's worth knowing what's the same and what's not.

A Firebase ID token, decoded

The header looks essentially identical to Auth0's — RS256, a kid that maps to a Google-published JWK Set. The payload is where the two diverge:

{
  "iss": "https://securetoken.google.com/ikit-prod-1a2b3",
  "aud": "ikit-prod-1a2b3",
  "auth_time": 1746950000,
  "user_id": "rqL8x3KbF4S0gQwXyZpZ1aB2cD3e",
  "sub": "rqL8x3KbF4S0gQwXyZpZ1aB2cD3e",
  "iat": 1746950400,
  "exp": 1746954000,
  "email": "[email protected]",
  "email_verified": true,
  "firebase": {
    "identities": {
      "email": ["[email protected]"]
    },
    "sign_in_provider": "password"
  }
}

The iss is always https://securetoken.google.com/PROJECT_ID — that prefix is your visual fingerprint for Firebase. aud is a bare project ID, not a URL like Auth0's. The firebase object contains platform-specific fields you'll never see in another vendor's JWT.

The 60-minute expiry

Firebase ID tokens expire after 60 minutes, no exception. Look at iat and exp and you'll see exactly 3600 seconds between them. This is shorter than most platforms — Auth0 access tokens default to 24 hours, Okta to 1 hour, Cognito to 1 hour. If your Firebase frontend forgets to call getIdToken(true) to refresh, every request after the hour mark will 401. The fix is to wire up onIdTokenChanged and let the SDK refresh automatically.

Auth0 vs Firebase — claims at a glance

Claim Auth0 Firebase
iss https://TENANT.auth0.com/ https://securetoken.google.com/PROJECT
aud Array of API audiences Single project ID string
sub auth0|HEX_ID Firebase UID (28 chars)
Default exp window 24 hours 1 hour
Algorithm RS256 RS256
Custom payload key permissions, scope firebase.sign_in_provider

Common Debugging Patterns

Most JWT debugging sessions are short once you know where to look. These three failures account for the overwhelming majority of "the token isn't working" tickets we've seen.

Expiry is the #1 cause of 401s

When a request 401s and the token looks fine, decode it and look at exp first. Compare it with the current Unix timestamp — Math.floor(Date.now() / 1000) in JavaScript, time.time() in Python, time.Now().Unix() in Go. If exp < now, the gateway is correct to reject it. If you're seeing this in dev, your clock is probably drifting against your auth server; install chrony or systemd-timesyncd, never trust laptop time.

"alg: none" is a known exploit vector

If you ever see a JWT header with "alg": "none" arriving at your service in 2026, treat it as an attack. The none algorithm was a 2010-era design mistake that meant "this token is unsigned" — and several libraries used to accept it by default. Every modern verifier rejects none outright, but a homegrown verifier that uses jwt.decode() instead of jwt.verify() will happily trust an unsigned token. Search your codebase for verify and make sure it's actually called.

The "Bearer " prefix trap

A token pasted into a decoder with the Bearer prefix will fail base64 decoding on the header. The space and the capital B are not legal base64url characters, so the parser breaks before it even tries to find the dots. Strip everything before the first letter of the actual JWT, including any trailing whitespace from a copy-paste. Most decoders handle this for you; if yours doesn't, the error will look obscure.

A 5-line JavaScript decoder

You don't always need a tool. For a quick console.log in DevTools:

const decode = (jwt) => {
  const [h, p] = jwt.split('.');
  return {
    header: JSON.parse(atob(h.replace(/-/g, '+').replace(/_/g, '/'))),
    payload: JSON.parse(atob(p.replace(/-/g, '+').replace(/_/g, '/')))
  };
};

This handles the base64url → base64 conversion that atob() needs. For binary payloads with non-ASCII bytes, you'll want a proper TextDecoder, but for the standard claims listed above this snippet is enough.

Why Browser-Only Decoding Matters

A JWT is a credential, even if it's "only" a debug token. Pasting one into a hosted service hands a stranger everything they need to impersonate the user until the token expires — and Auth0's 24-hour default gives them a full day. The risk isn't theoretical: in 2023, a major JWT-decoder site was caught logging every token submitted to it.

What a leaked token gives an attacker

The header and payload are public information once anyone has the token, but the combination of token + signature is the credential. An attacker who captures a valid signed token can send it as Authorization: Bearer ... to the issuer's API and act as that user. They don't need the secret. They don't need the user's password. They have, for the lifetime of exp, that user's session.

For HS256-signed tokens specifically, capturing the token in a setting where the secret is also exposed (a leaked .env, a misconfigured public bucket) lets the attacker forge new tokens on demand. Use a properly random secret generated with the iKit password generator or openssl rand -base64 64, and never paste it into a web form. Our guide on verifying file integrity with hashes covers the HMAC math that underpins HS256 in more detail.

How to tell a tool is really client-side

Three checks: open DevTools to the Network tab before decoding, paste a token, click decode. If you see a POST to the server with the token in the body, it's not browser-only. Second, disconnect from the internet — a real client-side tool keeps working offline. Third, view the page source and search for the decoding logic; if it's there in JavaScript, it ran in your tab. The iKit JWT Decoder passes all three checks. Most "free JWT decoder" sites do not.

When you actually need a server

There's exactly one JWT workflow that requires a backend: signature verification against an RS256 public key fetched from a JWKS endpoint. Even then, the JWKS fetch is the only network call — the signature math itself runs locally in the SubtleCrypto API. Decoding the header and payload to read the claims is always something you can and should do offline.

Related on iKit

Related posts