JWT Anatomy

Three base64url segments separated by dots — what is in each one, and why 'encoded' is not 'encrypted'.

SyntaxeyJhbGciOiJSUzI1NiIsImtpZCI6ImsxIn0.eyJzdWIiOiI0MiJ9.MEUCIQ...

A JSON Web Token is three base64url-encoded parts joined by dots:

header.payload.signature

Header

Describes how the token is signed. alg is the algorithm, kid is the key id (so the verifier knows which of several public keys to use).

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

Payload (the claims)

The facts being asserted. Registered claims have standard meanings; you can add your own.

{
  "iss": "https://dfg.com",        // issuer
  "sub": "42",                     // subject: the user
  "aud": "https://dfg.com/api",    // audience: who may accept it
  "exp": 1754313000,               // expires at (seconds since epoch)
  "iat": 1754312400,               // issued at
  "jti": "c1f9...",                // unique token id
  "scope": "orders:read"
}

Signature

Computed over base64url(header) + "." + base64url(payload) with the signing key. Change one byte of either part and verification fails.

The rule people miss

A JWT is signed, not encrypted. Anyone holding the token can read the payload — it is base64, not a cipher. Never put a password, a card number, or an internal secret in a JWT. Signing guarantees integrity and origin, nothing about confidentiality. (Encrypted variants exist — JWE — and are rarely what you want.)

Keep it small

The token travels in a header on every request. Some proxies cap header size around 8KB, and a bloated token slows every call. Put the user id and the scopes in; look everything else up.

Example

Example · bash
# Decode a JWT with nothing but base64 — no key needed. That is the point.
TOKEN='eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI0MiIsIm5hbWUiOiJBbGljZSIsImV4cCI6MTc1NDMxMzAwMH0.7mZ9Xk2rQ8vT1cB4nL6pY0dF3sJ5wA2eR8uI1oK7hG4'

# header
echo "$TOKEN" | cut -d. -f1 | base64 -d 2>/dev/null
# {"alg":"HS256","typ":"JWT"}

# payload — readable by anyone holding the token
echo "$TOKEN" | cut -d. -f2 | base64 -d 2>/dev/null
# {"sub":"42","name":"Alice","exp":1754313000}

# The signature is the only part you cannot produce without the key.

When to use it

  • A support engineer decodes a customer's token to check which audience and scopes it carries, without ever needing the signing key.
  • A code review rejects a token that embedded the user's full address and phone number, since every holder of the token can read them.
  • An API gateway reads the kid header to pick the right public key during a key rotation window where two keys are valid.

More examples

Building and reading the parts by hand

Writing it once by hand makes the security model obvious: the signature protects the bytes, it does not hide them.

Example · javascript
import { createHmac } from 'crypto';

const b64url = (obj) =>
  Buffer.from(JSON.stringify(obj)).toString('base64url');

const header  = { alg: 'HS256', typ: 'JWT' };
const payload = {
  iss: 'https://dfg.com',
  sub: '42',
  aud: 'https://dfg.com/api',
  iat: Math.floor(Date.now() / 1000),
  exp: Math.floor(Date.now() / 1000) + 600,
  scope: 'orders:read',
};

const signingInput = `${b64url(header)}.${b64url(payload)}`;
const signature = createHmac('sha256', SECRET)
  .update(signingInput)
  .digest('base64url');

const token = `${signingInput}.${signature}`;

// Reading it back requires no key at all:
const [h, p] = token.split('.');
console.log(JSON.parse(Buffer.from(p, 'base64url').toString()));
// { iss: 'https://dfg.com', sub: '42', ... }

Decoding without verifying — and when that is OK

Decoding on the client to schedule a refresh is fine. Decoding on the server to read the user id — which happens more often than you would hope — is a complete authentication bypass.

Example · javascript
// A client may decode its OWN token to read the expiry and schedule a refresh.
function decodePayload(token) {
  try {
    return JSON.parse(atob(token.split('.')[1].replace(/-/g, '+').replace(/_/g, '/')));
  } catch { return null; }
}

const claims = decodePayload(accessToken);
const msLeft = claims.exp * 1000 - Date.now();
setTimeout(refresh, Math.max(0, msLeft - 30_000));   // refresh 30s early

// ⚠️ This is a UX optimisation ONLY. A client-side decode proves nothing:
// the payload is attacker-controllable if the token came from anywhere else.
// The SERVER must always verify the signature before trusting a single claim.

Discussion

  • Be the first to comment on this lesson.