DEV Community

Snappy Tools
Snappy Tools

Posted on • Originally published at snappytools.app

JWT Tokens Decoded: What's Actually Inside That eyJ… String

You've seen JWT tokens everywhere — in Authorization: Bearer eyJ... headers, in cookies, in OAuth flows. But do you know what's actually inside one?

This post explains JWT structure from scratch, how to decode them manually, what each claim means, and the mistakes developers make when trusting them.


What Is a JWT?

A JWT (JSON Web Token) is a compact, URL-safe string that represents a set of claims. Claims are facts about a subject — usually a user — such as their ID, email, role, or when their session expires.

A JWT looks like this:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyXzEyMyIsIm5hbWUiOiJBbGljZSIsInJvbGUiOiJhZG1pbiIsImlhdCI6MTcwNDA2NzIwMCwiZXhwIjoxNzA0MDcwODAwfQ.SIGNATURE_HERE
Enter fullscreen mode Exit fullscreen mode

It has exactly three parts separated by two dots:

  1. Header — algorithm and token type
  2. Payload — the claims (the actual data)
  3. Signature — verifies the token hasn't been tampered with

Why Three Parts?

Part 1: The Header

The header is a Base64URL-encoded JSON object. Decode it and you get:

{
  "alg": "HS256",
  "typ": "JWT"
}
Enter fullscreen mode Exit fullscreen mode
  • alg tells the receiver which algorithm was used to sign the token (HS256 = HMAC-SHA256)
  • typ is always JWT

Part 2: The Payload

This is where the actual data lives. Also Base64URL-encoded JSON:

{
  "sub": "user_123",
  "name": "Alice",
  "role": "admin",
  "iat": 1704067200,
  "exp": 1704070800
}
Enter fullscreen mode Exit fullscreen mode

The sub, iat, and exp fields are registered claims defined in RFC 7519. The name and role fields are custom claims added by the application.

Part 3: The Signature

The signature is computed as:

HMAC-SHA256(
  base64url(header) + "." + base64url(payload),
  secret_key
)
Enter fullscreen mode Exit fullscreen mode

This is what prevents tampering. If anyone changes the payload (say, promoting their role from user to admin), the signature won't match — and a proper server-side validation will reject the token.


Decoding vs. Verifying

Here's the critical distinction developers often get wrong:

Decoding = reading the payload (anyone with the token string can do this)

Verifying = checking the signature (requires the signing key)

The payload is not encrypted. It's just Base64URL-encoded — a reversible transformation, not a security measure. Anyone who holds the token string can read every claim in it.

The signature ensures the payload wasn't modified after it was issued. But decoding is still useful for debugging: to check what claims a token actually contains, what expiry time was set, which user ID is included.


Decoding a JWT Manually

Base64URL decoding is straightforward in JavaScript:

function decodeJWT(token) {
  const [headerB64, payloadB64] = token.split('.');

  function b64urlDecode(str) {
    // Base64URL → Base64
    let s = str.replace(/-/g, '+').replace(/_/g, '/');
    // Add padding
    while (s.length % 4) s += '=';
    // Decode UTF-8 bytes
    const bytes = Uint8Array.from(atob(s), c => c.charCodeAt(0));
    return JSON.parse(new TextDecoder().decode(bytes));
  }

  return {
    header: b64urlDecode(headerB64),
    payload: b64urlDecode(payloadB64),
  };
}
Enter fullscreen mode Exit fullscreen mode

The key steps: replace - with + and _ with / (Base64URL → standard Base64), add = padding, decode with atob(), then JSON-parse.

Or just paste the token into SnappyTools JWT Decoder — it decodes header, payload, and expiry status in one click with no setup.


Standard Claims (RFC 7519)

Claim Full Name Meaning
iss Issuer Who issued the token
sub Subject Who the token is about (usually user ID)
aud Audience Who the token is intended for
exp Expiration Time Unix timestamp — reject after this time
nbf Not Before Unix timestamp — reject before this time
iat Issued At When the token was issued
jti JWT ID Unique ID — used to prevent replay attacks

The timestamps (exp, nbf, iat) are Unix timestamps in seconds — not milliseconds. A common bug is checking exp < Date.now() (which is in milliseconds) instead of exp < Date.now() / 1000.


Common Signing Algorithms

The alg header tells the server how to verify the signature:

Symmetric (shared secret):

  • HS256, HS384, HS512 — HMAC with SHA hash. The same key signs and verifies. Good for single-service tokens. Keep the key secret from clients.

Asymmetric (public/private key):

  • RS256, RS384, RS512 — RSA. Sign with the private key, verify with the public key. Good for distributed systems where multiple services verify tokens.
  • ES256, ES384, ES512 — ECDSA. Same idea, smaller signatures.

⚠️ Never accept alg: none. This means the token has no signature and anyone can forge it. A class of vulnerabilities exists where attackers modify the header to "alg": "none" and strip the signature — if the server doesn't validate the algorithm, it accepts the forged token. Always specify which algorithms you accept.


Mistakes Developers Make

1. Trusting the decoded payload without verifying

// ❌ Wrong: decoding without verifying
const payload = JSON.parse(atob(token.split('.')[1]));
if (payload.role === 'admin') { ... }
Enter fullscreen mode Exit fullscreen mode

If you decode without verifying the signature, an attacker can forge any payload. Always verify server-side.

2. Using JWT for sessions and then logging out

JWTs are stateless — there's no server-side record to delete. If a user logs out, the token is still valid until exp. You need a token denylist (or short expiry + refresh tokens) to handle early revocation.

3. Putting sensitive data in the payload

The payload is readable by anyone. Don't put passwords, PII, or secrets in JWT claims.

4. No expiry

A token without an exp claim is valid forever. Always set an expiry — even a generous one — so stolen tokens eventually become useless.

5. Storing access tokens in localStorage

localStorage is accessible to JavaScript — including injected scripts (XSS). Prefer HTTP-only cookies for access tokens where possible.


Debugging JWTs in Practice

When a JWT-authenticated request fails, the first thing to do is decode the token and check:

  1. Is it expired? Check exp against current time.
  2. Is it for the right audience? Check aud.
  3. Is it from the right issuer? Check iss.
  4. Does it have the right algorithm? Check alg in the header.
  5. Does the payload have the expected claims? Check sub, role, scope, etc.

You can do this manually (decode the Base64URL parts), use your IDE's debugger, or use a browser tool like SnappyTools JWT Decoder — paste the token, see everything decoded instantly, get the expiry countdown, and read all standard claims with human-readable labels.


Summary

  • A JWT is header.payload.signature — all Base64URL-encoded
  • The payload is not encrypted — anyone can read it
  • The signature prevents tampering — but only if you verify it server-side
  • Standard claims (exp, iat, sub, iss) have defined meanings — use them
  • Never accept alg: none, always set an expiry, and don't store secrets in the payload

Next time you see an eyJ... token, you'll know exactly what's inside.

Top comments (0)