{ }jsonkitOpen

Understanding JWTs: structure, claims and verification

8 min read · updated 2026-07-12

A JSON Web Token (JWT) carries claims between two parties in a compact, URL-safe string. Claims are statements about a user or session. You have seen one before: a long string with two dots in it, sent in an Authorization: Bearer header or a cookie. JWTs are self-contained. The token holds the data the server needs, and a signature shows if anyone changed it.

Anyone can read a JWT. The payload is base64url-encoded, not encrypted. Most JWT security bugs come from mixing up *reading* a token with *trusting* it. This guide covers the three parts of a JWT, the registered claims, how HS256 and RS256 signatures work, and why signature verification is what protects your API. Paste a real token into the JWT Decoder as you read to see each part.

The anatomy of a JWT: three base64url parts

A JWT is one string made of three parts separated by dots: header.payload.signature. Each part is base64url-encoded on its own. Base64url is a URL-safe variant of base64. It swaps + and / for - and _ and drops the = padding, so the token fits in URLs, headers and cookies without escaping.

A JWT: header.payload.signature (line breaks added for readability)text
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
.
eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkFkYSIsImFkbWluIjp0cnVlLCJpYXQiOjE3MjAwMDAwMDB9
.
SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c

The first two parts are encoded JSON. Decode the first segment to get the header, which says how the token was signed. Decode the second to get the payload, also called the claims set. The third part is the signature, computed over the first two.

Why do JWTs start with ey?

Almost every JWT starts with eyJ. The header is a JSON object, so its first two characters are {". In base64, {" always encodes to eyJ. The payload is also a JSON object, so the second segment starts with eyJ too. If you see a string that starts with eyJ and has two dots, it is very likely a JWT.

The header

The header is a small JSON object that names the signing algorithm (alg) and the token type (typ). It may also carry a key ID (kid). The verifier uses kid to pick the right key when several keys are in rotation.

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

The payload and registered JWT claims

The payload is a JSON object, and its fields are called claims. The JWT spec (RFC 7519) defines a set of registered claims. These are short, standard names with agreed meanings. You can add your own private claims too, such as roles, tenant IDs or feature flags. Libraries and identity providers only understand the registered ones.

ClaimNameMeaning
issIssuerWho issued the token, for example your auth server's URL.
subSubjectWho the token is about, usually a user ID.
audAudienceWho the token is for. This is the API that should accept it.
expExpiration timeUnix timestamp after which the token must be rejected.
nbfNot beforeUnix timestamp before which the token is not yet valid.
iatIssued atUnix timestamp when the token was created.
jtiJWT IDA unique ID for this token. Used for revocation and replay detection.

The time claims (exp, nbf, iat) are NumericDate values. They count seconds since the Unix epoch (1970-01-01 UTC), not milliseconds. A common bug is passing JavaScript's Date.now() directly. It returns milliseconds, so the timestamp lands thousands of years in the future.

The jti claim

The jti (JWT ID) claim gives each token a unique ID. RFC 7519 says the issuer must assign it so that two tokens are very unlikely to share a value. A random UUID works well. The claim is optional. It matters when you need to track single tokens: a server can store used jti values to reject a replayed token, or keep a denylist of revoked IDs until each token's exp passes.

A realistic decoded payloadjson
{
  "iss": "https://auth.example.com",
  "sub": "user_8f3a21",
  "aud": "https://api.example.com",
  "iat": 1720000000,
  "exp": 1720003600,
  "roles": ["editor"],
  "jti": "a1b2c3d4"
}

The signature: how HS256 and RS256 work

The signature is what makes a JWT trustworthy. It is computed over the string base64url(header) + "." + base64url(payload) with the algorithm named in alg. Change one byte of the header or payload and the signature no longer matches, so verification fails. Two families of algorithms are common.

HS256: symmetric (HMAC with SHA-256)

HS256 uses one shared secret. The same secret signs and verifies the token, so every party that can verify a token can also forge one. That is fine when the issuer and the verifier are the same service, or trust each other fully. HS256 is fast and simple.

HS256 signature computationtext
signature = HMAC-SHA256(
  key   = your_shared_secret,
  data  = base64url(header) + "." + base64url(payload)
)

RS256: asymmetric (RSA with SHA-256)

RS256 uses a key pair. The issuer signs with a private key it never shares. Anyone can verify with the matching public key. Use it when many separate services need to verify tokens but must not be able to create them, such as third parties checking tokens from an identity provider. The public key is often published at a JWKS endpoint.

HS256RS256
Key typeOne shared secretPrivate and public key pair
Who can verifyAnyone with the secretAnyone with the public key
Who can signAnyone with the secretOnly the private key holder
Best forOne trusted serviceMany verifiers or third parties
SpeedFasterSlower to sign, fast to verify

Decoding is not verifying

This is the most important idea in this guide. Decoding a JWT means base64url-decoding the header and payload to read the JSON inside. It needs no key and proves nothing. Verifying means recomputing the signature with a key, checking that it matches, and then checking the claims. A decoded payload that has not been verified is attacker-controlled input. Treat it like a raw query string a user typed.

You can decode a JWT with only the standard library. This code reads the payload without ever seeing a key.

Decoding a JWT in Node.js. It reads the claims but proves nothing about who made them.js
function decodeJwt(token) {
  const [headerB64, payloadB64] = token.split(".");
  const decode = (part) =>
    JSON.parse(Buffer.from(part, "base64url").toString("utf8"));
  return { header: decode(headerB64), payload: decode(payloadB64) };
}

const { header, payload } = decodeJwt(token);
console.log(header.alg);   // "HS256"
console.log(payload.sub);  // "user_8f3a21", but is it real? Unknown.

Decoding is fine for uses that do not involve security. Examples: inspecting a token while debugging, reading exp on the client to decide when to refresh, or logging a jti. The JWT Decoder does this kind of inspection, and it can also verify HS256, HS384 and HS512 signatures if you paste the secret. On the server, never let a decoded claim make an authorization decision.

JWT DecoderOpen the tool→

How JWT verification works, step by step

Verification is a sequence of checks. Skip any one of them and you can open a hole. A good library runs all of them for you, which is the best reason not to write JWT verification yourself.

  1. Split and decode the header to read the claimed alg and any kid.
  2. Select the key, but only for algorithms on your allow-list. If the header names an alg you did not configure, reject the token.
  3. Recompute the signature over header.payload with that key. Compare it to the token's signature with a constant-time comparison.
  4. Validate the claims. Reject the token if exp is in the past or nbf is in the future. Check that iss and aud match what you expect.
  5. Only now can you trust the payload and act on its claims.
Verifying a JWT with the jsonwebtoken libraryjs
const jwt = require("jsonwebtoken");

try {
  // Pin the algorithm(s) and audience/issuer explicitly.
  const payload = jwt.verify(token, SHARED_SECRET, {
    algorithms: ["HS256"],        // reject anything else, incl. "none"
    audience: "https://api.example.com",
    issuer: "https://auth.example.com",
    clockTolerance: 5,            // seconds, for minor clock skew
  });
  // Reaching here means the signature AND exp/nbf/aud/iss all passed.
  console.log("Authenticated subject:", payload.sub);
} catch (err) {
  // TokenExpiredError, JsonWebTokenError, NotBeforeError, etc.
  console.error("Rejected:", err.message);
}

Security best practices and the alg:none attack

Most JWT vulnerabilities are not breaks in the cryptography. They are verification steps that someone skipped. A few habits prevent the common ones.

  • Always verify the signature. Never make an authorization decision from a payload you only decoded.
  • Always check `exp`. If you never check expiry, a leaked token works forever.
  • Pin the algorithm. Configure the exact alg you accept. Do not let the token's header choose.
  • Keep secrets out of the payload. Anyone holding the token can read it.
  • Use a strong secret or key. With HS256, an attacker can brute-force a short or guessable secret offline from one captured token.
  • Validate `aud` and `iss` so a token issued for one service cannot be replayed against another.

The alg:none attack

The JWT spec defines an "alg": "none" value that means "unsecured, no signature". It exists for cases where something else protects integrity, and it is a well-known trap. In the classic attack, someone takes a valid token and edits the payload, for example changing "admin": false to true. They set the header algorithm to none and delete the signature, leaving a trailing dot.

A forged alg:none token. The header says none and the signature is empty.text
eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJzdWIiOiJ1c2VyIiwiYWRtaW4iOnRydWV9.

A verifier that trusts the header's alg sees none, skips the signature check and accepts the forged claims. The fix is the allow-list from the previous section. If your code only accepts HS256, or whatever you configured, it rejects a token that claims none before reading the payload. Modern libraries reject none by default. Pinning the algorithm makes that explicit, so you do not depend on a default you did not set.

Frequently asked questions

Is a JWT encrypted?

No. A standard JWT (JWS) is signed, not encrypted. The header and payload are only base64url-encoded, so anyone with the token can read every claim. If the contents must stay private, use JWE (JSON Web Encryption) or keep sensitive data out of the token.

What is the difference between decoding and verifying a JWT?

Decoding base64url-decodes the header and payload so you can read the JSON. It needs no key and proves nothing. Verifying recomputes the signature with a key, checks that it matches, and checks claims like exp, aud and iss. Only a verified token should drive authorization decisions.

Why does every JWT start with eyJ?

The header is a JSON object, so it starts with the characters {". Base64 encodes {" as eyJ, so the first segment starts with eyJ. The payload is a JSON object too, so the second segment starts with eyJ as well.

Should I use HS256 or RS256?

Use HS256, a single shared secret, when the same service issues and verifies tokens. It is simpler and faster. Use RS256, a private and public key pair, when many separate services must verify tokens but must not create them. Verifiers only ever hold the public key.

What is the alg:none attack?

An attacker edits a token's payload, sets the header algorithm to "none" and removes the signature. A verifier that trusts the header's alg skips the signature check and accepts the forgery. Prevent it by pinning an explicit list of accepted algorithms, so any token that claims none is rejected.

Why is my JWT always expired or never expiring?

The exp, iat and nbf claims are in seconds since the Unix epoch, not milliseconds. JavaScript's Date.now() returns milliseconds, which puts expiry thousands of years in the future. Divide by 1000 to fix it. A token that is always expired usually points to server clock skew, so allow a few seconds of tolerance.

Can I trust the data in a JWT payload on the client?

You can read it for things that do not involve security, like showing a username or deciding when to refresh. Do not make security decisions from it on the client. The client cannot safely hold the verification key, and any value could be forged. Check authorization on the server.