All posts

How to read JWT claims

Read a JWT's header, payload and signature: how Base64URL works, what iss, sub, aud, exp, nbf, iat and jti mean, and a sample token decoded step by step.

By Last reviewed 6 min readJWTAuthentication

A JWT looks like noise, but it is three Base64URL strings joined by dots, and two of them are plain JSON. When an API answers 401 or 403, reading those claims usually tells you why within a minute: the token was minted for a different audience, it expired an hour ago, or the scope you need isn't there.

The quickest route is to decode the token in your browser with the JWT Decoder, which splits the parts, pretty-prints the JSON and turns timestamps into dates. This guide covers what it does under the hood, so you can read a token anywhere: a log line, a failing test, a curl transcript.

The three parts of a JWT

Almost every JWT you meet is a JSON Web Signature in compact form (RFC 7515):

BASE64URL(header) . BASE64URL(payload) . BASE64URL(signature)
  • Header: a JSON object describing the token. It holds alg (how it was signed), usually typ: "JWT", and often kid, the ID of the key that signed it.
  • Payload: the claims set, a JSON object of statements about the subject. This is what you usually came to read.
  • Signature: bytes computed over the exact ASCII string header.payload, meaning the first two segments and the dot between them. It isn't JSON; it is binary data written in Base64URL.

If you count four dots instead of two, you have an encrypted JWT (JWE, RFC 7516). Its payload can't be read without the decryption key, and no decoder will show you the claims.

Base64URL in two minutes

Base64URL is ordinary Base64 with two characters swapped so the result is safe in URLs and headers: + becomes - and / becomes _. JWTs also drop the trailing = padding. Every 3 bytes of input become 4 characters, so a standard Base64 decoder needs the characters swapped back and the padding restored to a multiple of 4.

That is also why tokens start with eyJ: it is how {" followed by a letter comes out in Base64URL. If you spot eyJ at the start of a long string in a log, it is very likely a JWT or some other Base64URL-encoded JSON.

To decode a segment by hand:

# Node.js has a base64url codec built in
node -e 'console.log(Buffer.from(process.argv[1], "base64url").toString())' eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9

# Python needs the padding put back first
python3 -c 'import base64, sys; s = sys.argv[1]; print(base64.urlsafe_b64decode(s + "=" * (-len(s) % 4)).decode())' eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9

Both print {"alg":"HS256","typ":"JWT"}. One trap: the payload is UTF-8. The browser's atob() returns one character per byte, so a name like "José" comes out garbled unless you pass the bytes through TextDecoder, which is what the DevPrix decoder does. The Base64 encoding guide covers the alphabet and padding rules in more depth.

A worked decode, segment by segment

Here is a sample access token, as you might copy it from a request log. The line breaks are only there to fit the page: the JWT Decoder strips all whitespace, and a Bearer prefix too if you copy the whole header value, so you can paste the block exactly as shown.

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.
eyJpc3MiOiJodHRwczovL2F1dGguZXhhbXBsZS5jb20vIiwic3ViIjoidXNlcl84
ZjNhMmMiLCJhdWQiOiJodHRwczovL2FwaS5leGFtcGxlLmNvbSIsImlhdCI6MTc5
MjQ4NjgwMCwibmJmIjoxNzkyNDg2ODAwLCJleHAiOjE3OTI0OTA0MDAsImp0aSI6
IjRmMWM5YTdlLTJiM2QtNGU1Zi04YTZiLTFjMmQzZTRmNWE2YiIsInNjb3BlIjoi
b3JkZXJzOnJlYWQgb3JkZXJzOndyaXRlIiwicm9sZXMiOlsic3VwcG9ydCJdfQ.
CIMGchg31ZesoUd1r-ZBeLG4cPkVptSgf5mTiX2Kgt4

The first segment, 36 characters long, decodes to the header:

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

The second segment decodes to the payload. Pretty-printed:

{
  "iss": "https://auth.example.com/",
  "sub": "user_8f3a2c",
  "aud": "https://api.example.com",
  "iat": 1792486800,
  "nbf": 1792486800,
  "exp": 1792490400,
  "jti": "4f1c9a7e-2b3d-4e5f-8a6b-1c2d3e4f5a6b",
  "scope": "orders:read orders:write",
  "roles": ["support"]
}

The third segment is 43 characters, which is 32 bytes: exactly the output size of HMAC-SHA256, consistent with alg: HS256. An RS256 token signed with a 2048-bit key would carry a 342-character signature instead.

This sample was signed with the demo secret devprix-demo-secret-do-not-use-in-prod-0001. Paste the token and that secret into the JWT Builder's Verify token mode and it reports a valid HS256 signature. Change a single character of the payload and it no longer does.

Registered claims: what each one tells you

RFC 7519 registers seven claim names. All of them are optional at the JWT level, and profiles built on top make some of them mandatory: an OpenID Connect ID token, for example, must carry iss, sub, aud, exp and iat.

  • iss (issuer): who minted the token. It is a case-sensitive string and verifiers compare it exactly, so https://auth.example.com/ and https://auth.example.com are different issuers. A missing trailing slash is a classic cause of "invalid issuer" errors.
  • sub (subject): who the token is about. It only has to be unique within its issuer, so the stable identity of a user is the pair iss plus sub, not an email address.
  • aud (audience): who the token is for, as a single string or an array. If aud is present and the service reading the token doesn't find itself in it, the spec says the token must be rejected. Here only https://api.example.com should accept it.
  • exp (expiration time): the token must not be accepted on or after this moment. 1792490400 is 2026-10-20 10:00:00 UTC.
  • nbf (not before): the token must not be accepted before this moment. Here it equals iat, so the token is valid from the second it was issued.
  • iat (issued at): when the token was minted, 2026-10-20 09:00:00 UTC. exp - iat is 3600, so this is a one-hour token.
  • jti (JWT ID): a unique ID for this particular token, used to detect replays or to put one token on a deny list.

The three time claims are NumericDate values: seconds since 1970-01-01T00:00:00Z, not milliseconds. The guide to exp and iat works through the conversions and the bugs that come from getting the unit wrong. Because this sample's hour has passed, the decoder flags it as expired, which is expected.

Custom claims: scope, roles and friends

Everything outside the registered set is up to the issuer, and this is where providers differ most.

  • scope: in OAuth 2.0 access tokens it is conventionally one space-separated string, which is how RFC 8693 registers it. Split it on spaces before checking. "orders:read_all".includes("orders:read") is true, so a substring check grants the wrong permission. Some providers use scp instead, sometimes as an array.
  • roles, groups, permissions: usually arrays of strings, with no common spelling across providers. Read your provider's documentation rather than guessing.
  • Namespaced claims: names such as https://example.com/roles. RFC 7519 asks for public claim names to be registered or collision-resistant, and a URI you control is the usual way to get there.

Whatever the name, assume anyone holding the token can read it. Signing stops changes; it doesn't hide anything. Keep secrets, and personal data you wouldn't want in a log, out of the payload.

A reading order that finds most auth bugs

When a request fails with a token that looks fine, check the decoded token in this order:

  1. Header: is alg the one your API expects, and does kid match a key it knows?
  2. iss: an exact string match with the issuer your API is configured for, trailing slash included.
  3. aud: does it contain your API's identifier, or was the token minted for a different service?
  4. exp and nbf: compared with the clock on the server that rejected the request, not the one on your laptop.
  5. scope or roles: does the token carry what this endpoint requires?

The decoder's Claims view helps with steps 2 to 5. It labels the registered claims and common ones such as scope, and shows each time claim as a UTC date next to the raw number. None of this proves the token is genuine, though. Reading claims and verifying them are different jobs, as JWT decoder vs JWT verifier explains.

Next steps

Paste a token from your own development environment into the JWT Decoder and walk through the checklist above. If the algorithm in the header is unfamiliar, HS256 vs RS256 for beginners explains what it tells you about who holds the keys.

Paste anything. devprix picks the tool.

Press ⌘K on any page, paste JSON, a JWT, a timestamp or a URL, and the right tool opens with your data filled in.