DEV Community

William Andrews
William Andrews

Posted on • Originally published at devcrate.net

How to decode and debug a JWT without installing anything

You're staring at a string that looks like this:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
Enter fullscreen mode Exit fullscreen mode

Something in your auth flow is broken. The API is returning 401s, the user session isn't persisting, or someone handed you a token and asked why it isn't working. You need to see what's inside it — right now, without installing a library or setting up a project.

This guide shows you how to decode any JWT instantly in the browser, what every part of the token means, and how to diagnose the most common JWT errors from the decoded contents alone.


The anatomy of a JWT

A JWT is three Base64URL-encoded strings separated by dots. There's no encryption happening at the decoding stage — the payload is readable by anyone who has the token. The signature at the end is what makes tampering detectable, but decoding the contents requires no secret key.

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9          ← header
.
eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ  ← payload
.
SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c  ← signature
Enter fullscreen mode Exit fullscreen mode

The header

The header tells you which algorithm was used to sign the token:

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

Common algorithm values: HS256 (HMAC-SHA256, symmetric), RS256 (RSA-SHA256, asymmetric), ES256 (ECDSA). If you see "alg": "none" — that's a serious red flag. It means the token has no signature and should be rejected by any properly configured server.

The payload

The payload contains claims — key-value pairs that assert things about the user or session:

{
  "sub": "1234567890",
  "name": "John Doe",
  "email": "john@example.com",
  "role": "admin",
  "iat": 1516239022,
  "exp": 1516242622,
  "iss": "https://auth.example.com",
  "aud": "https://api.example.com"
}
Enter fullscreen mode Exit fullscreen mode

The signature

The signature is a hash of the header and payload, signed with the server's secret or private key. You cannot verify it without that key — but you don't need to verify it to read the payload. Decoding and verifying are two separate operations.


Standard claims and what they mean

sub (Subject) — the principal this token is about, typically a user ID. This is what your backend uses to identify who the token belongs to.

iss (Issuer) — who created and signed the token. Often a domain or auth service URL. Your server should validate that this matches the expected issuer.

aud (Audience) — who the token is intended for. A token issued for api.example.com should be rejected by other-api.example.com. Mismatched audience is a common source of 401 errors.

exp (Expiration) — a Unix timestamp after which the token must be rejected. This is the most common reason for 401 errors in production.

iat (Issued At) — when the token was created, as a Unix timestamp. Useful for calculating how old the token is.

nbf (Not Before) — the token must be rejected before this time. Less common, used when tokens are issued in advance.

jti (JWT ID) — a unique identifier for this specific token, used to prevent replay attacks.


How to decode a JWT in the browser right now

Paste the token into DevCrate's JWT Debugger and you'll see the header and payload decoded instantly — no account, no install, nothing sent to a server.

If you prefer to do it in code:

function decodeJwt(token) {
  const parts = token.split('.');
  if (parts.length !== 3) throw new Error('Invalid JWT format');

  const decode = (str) => {
    const base64 = str.replace(/-/g, '+').replace(/_/g, '/');
    const padded = base64.padEnd(base64.length + (4 - base64.length % 4) % 4, '=');
    return JSON.parse(atob(padded));
  };

  return {
    header:  decode(parts[0]),
    payload: decode(parts[1]),
  };
}

const { header, payload } = decodeJwt(token);
console.log(payload.exp); // expiry timestamp
console.log(payload.sub); // user ID
Enter fullscreen mode Exit fullscreen mode

Note what this does and doesn't do: it reads the payload without verifying the signature. Fine for debugging — never use this in place of server-side verification for actual auth decisions.


Diagnosing common JWT errors from the decoded payload

401 — "Token expired"

Check the exp claim. Convert it to a readable date:

new Date(payload.exp * 1000).toLocaleString()
// → "4/30/2026, 11:42:00 PM"
Enter fullscreen mode Exit fullscreen mode

If that date is in the past, the token is expired. The fix is on the client — it needs to refresh the token before it expires or request a new one after receiving a 401.

401 — "Invalid audience"

Check the aud claim. Common mismatches:

  • Token issued for https://api.example.com, server expects api.example.com (no scheme)
  • Token issued for staging, hitting production
  • Token issued for one service being used against a different service
  • aud is an array and the server is checking for a single string match

401 — "Invalid issuer"

Check the iss claim. Same class of problem as audience mismatch. Common in multi-environment setups where a dev token gets used against a prod server, or when an auth provider URL changes.

403 — "Insufficient permissions"

The token is valid but the user doesn't have access. Look for custom claims in the payload — role, permissions, scope, groups. These are application-specific:

{
  "sub": "user_123",
  "role": "viewer",
  "permissions": ["read"],
  "scope": "openid profile"
}
Enter fullscreen mode Exit fullscreen mode

Token present but user isn't authenticated

Check whether the token is being sent correctly:

Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
Enter fullscreen mode Exit fullscreen mode

Common mistakes: "Bearer" misspelled or missing, double space between Bearer and the token, or the token has been URL-encoded in transit (look for %2B or %3D in the token string).

Token looks valid but server rejects it

If the header and payload decode cleanly and all claims look correct, the problem is the signature. Possible causes:

  • Token signed with a different secret than the server is using to verify
  • Token was modified after signing
  • Algorithm mismatch — HS256 vs RS256 when switching auth libraries
  • Clock skew — server's system time is off enough that exp and nbf checks fail

Reading tokens from real-world locations

From browser storage: DevTools → Application → Local Storage or Session Storage → look for keys like token, access_token, auth_token.

From browser cookies: DevTools → Application → Cookies → look for anything with a value starting with ey.

From a network request: DevTools → Network → click a failing request → Headers tab → find the Authorization header → copy the value after "Bearer ".

From curl:

TOKEN=$(curl -s -X POST https://api.example.com/login \
  -H "Content-Type: application/json" \
  -d '{"email":"user@example.com","password":"secret"}' \
  | jq -r '.access_token')
echo $TOKEN
Enter fullscreen mode Exit fullscreen mode

One thing to never do with a JWT debugger

Never paste a production JWT containing real user data into a third-party website. Most online JWT tools send the token to their servers — your token contains claims about real users, and a valid token can be used to make authenticated API calls.

DevCrate's JWT Debugger decodes entirely in the browser using JavaScript — the token never leaves your machine. You can verify this by opening DevTools → Network while using the tool and confirming no requests are made when you paste a token.

Top comments (0)