jsonpost
JWTAuthenticationSecurity

Understanding and Decoding JSON Web Tokens (JWT)

Learn JWT structure, base64url encoding, and standard claims — how to decode tokens in JavaScript, why decoding is not verifying, and security best practices.

JSONPost··5 min read
Decoding JWTs

JSON Web Tokens are everywhere in modern authentication — in your Authorization headers, your cookies, and your OAuth flows. They look like opaque gibberish, but they are mostly just JSON you can read. This guide explains exactly what a JWT contains, how to decode one, and the security pitfall that trips up even experienced developers.

Anatomy of a JWT

A JWT is three base64url-encoded strings joined by dots:

header.payload.signature

Each segment has a job:

  • Header — metadata about the token, mainly the signing algorithm.
  • Payload — the claims: who the user is, when the token expires, who issued it.
  • Signature — a cryptographic seal proving the first two parts were not tampered with.

Here is a real-looking token (line-wrapped for readability; in practice it is one line):

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.
eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkphbmUgRG9lIiwiaWF0IjoxNTE2MjM5MDIyLCJleHAiOjE1MTYyNDI2MjJ9.
SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c

base64url encoding

The segments are encoded with base64url, a URL-safe variant of base64. Two characters differ: + becomes -, and / becomes _. Padding = characters are usually stripped. This matters because standard base64 decoders may choke on a raw JWT segment — you sometimes need to swap the characters back and re-add padding before decoding.

base64url is encoding, not encryption. Anyone who has the token can read the header and payload. Keep reading — that fact is the whole security lesson here.

Decoding the header

Decoding the first segment yields the header JSON:

{
  "alg": "HS256",
  "typ": "JWT"
}

The alg field tells the recipient which algorithm signed the token (here, HMAC-SHA256). The typ is almost always JWT. Some tokens add a kid (key ID) to select the right verification key.

Decoding the payload

The second segment decodes to the claims:

{
  "sub": "1234567890",
  "name": "Jane Doe",
  "iat": 1516239022,
  "exp": 1516242622
}

Standard registered claims

The JWT spec reserves several short claim names. You will see these constantly:

  • ississuer: who created the token (e.g. https://auth.example.com).
  • subsubject: the principal the token is about, usually a user ID.
  • audaudience: who the token is intended for (an API or client ID).
  • expexpiration: a Unix timestamp after which the token is invalid.
  • iatissued at: when the token was created.
  • nbfnot before: the token is invalid before this time.

The exp, iat, and nbf values are seconds since the Unix epoch, not milliseconds. A token with these claims might look like:

{
  "iss": "https://auth.example.com",
  "sub": "user_42",
  "aud": "billing-api",
  "iat": 1516239022,
  "nbf": 1516239022,
  "exp": 1516242622,
  "scope": "read:invoices write:invoices"
}

Everything outside the registered set — like name or scope above — is a custom claim you define for your application.

Decoding a JWT in JavaScript

In the browser, atob decodes base64. You split on dots, fix the base64url characters, and parse the JSON. A small helper:

function decodeJwt(token) {
  const [headerB64, payloadB64] = token.split(".");

  const fromBase64Url = (segment) => {
    // restore standard base64, then add padding
    const base64 = segment.replace(/-/g, "+").replace(/_/g, "/");
    const padded = base64.padEnd(
      base64.length + ((4 - (base64.length % 4)) % 4),
      "="
    );
    return JSON.parse(atob(padded));
  };

  return {
    header: fromBase64Url(headerB64),
    payload: fromBase64Url(payloadB64),
  };
}

const { header, payload } = decodeJwt(token);
console.log(header.alg); // "HS256"
console.log(payload.sub); // "1234567890"

In Node.js you can decode without any third-party library using Buffer:

const payload = JSON.parse(
  Buffer.from(token.split(".")[1], "base64url").toString()
);
console.log(payload.exp);

Note that Node's base64url encoding handles the character swapping and padding for you. To check whether a token is expired, compare exp against the current time in seconds:

const isExpired = payload.exp * 1000 < Date.now();

You can do all of this interactively — paste a token into the JWT decoder to see the header and payload as formatted JSON without writing any code.

Decoding is NOT verifying

This is the single most important point in this article. Decoding a JWT only reads the base64url data. It does not check the signature, so it tells you nothing about whether the token is authentic.

Anyone can craft a token with whatever payload they like:

# an attacker base64url-encodes their own header and payload
# the result decodes "successfully" but the signature is garbage

To trust a token, the server must verify the signature using the secret (for HMAC algorithms like HS256) or the public key (for RS256/ES256). Only the party holding the signing key can produce a valid signature, and only signature verification proves the claims were not altered. Verification also re-checks exp, nbf, iss, and aud. Never make an authorization decision based on a decoded-but-unverified token.

A short rule of thumb: decode in the client for display, verify on the server for trust.

Security best practices

  • Never trust an unverified token. Always verify the signature server-side before honoring any claim.
  • Use short expiry. Keep exp tight — minutes to an hour for access tokens — and use refresh tokens for longer sessions. A leaked short-lived token is worth little.
  • Never put secrets in the payload. Remember, the payload is readable by anyone. No passwords, no API keys, no PII you would not print on a postcard.
  • Validate alg. Reject tokens whose algorithm does not match what you expect, and never accept alg: none.
  • Check aud and iss. Make sure the token was minted by your issuer and intended for your service.
  • Store tokens carefully. Prefer HttpOnly, Secure cookies over localStorage to reduce XSS exposure.

Conclusion

A JWT is three base64url segments — header, payload, signature — and the first two are plain JSON you can read at a glance. Knowing the standard claims and how to decode them in JavaScript demystifies the whole flow. Just remember the line that separates a working auth system from a breach: decoding shows you what a token says, but only verifying the signature proves you can believe it.

Want to inspect a token right now? Paste it into the JWT decoder to see the decoded header and payload, then pretty-print the result with the JSON formatter. Find more developer tools and guides on the JSONPost blog.

Keep reading