Developer

JWT Explained: What Is Inside a Token and How It Is Verified

A JSON Web Token is three Base64url strings joined by dots. Here is what each one holds, why anyone can read it, what the signature proves, and how to find out why a token expired.

JWT Explained: What Is Inside a Token and How It Is Verified
Photo by Nathan Thomas on Pexels

Key takeaways

  • A JWT is header.payload.signature. The first two parts are Base64url text that anyone can read without a key.
  • Decoding shows you the claims. Only checking the signature with the right key shows they are genuine.
  • HS256 uses one shared secret to sign and to check. RS256 signs with a private key and checks with a public key, which is safer across services.
  • Never put passwords or personal data in the payload. Always set an expiry. Reject tokens whose header says alg none.
  • To debug an expired token, turn the exp claim from Unix seconds into a date. Then compare it with the server clock in UTC.

A JSON Web Token (JWT) is three pieces of text joined by dots. The first is a header that says how the token is signed. The second is a payload of claims, such as who the user is and when the token ends. The third is a signature that proves the first two parts were not changed. The header and payload are only encoded, not locked, so anyone who holds the token can read them. The signature is what makes a token trustworthy. Only someone with the right key can check it. This guide walks through each part and the claims you will meet. It covers the two common signing methods and the mistakes that turn JWTs into security holes. It ends with a simple routine for debugging a token that gets rejected.

The three parts of a token

Here is a token as you will see it in an Authorization: Bearer header, a cookie or a log line:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c

Split it at the two dots. Decode the first two parts. You get plain JSON.

Header

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

alg names the signing method. typ says this is a JWT. Some tokens add kid, a key ID. It tells the checker which public key to fetch.

Payload

{"sub":"1234567890","name":"John Doe","iat":1516239022}

The payload is a flat JSON object of claims. A claim is a fact about the user or the token. Some names are standard. The rest are whatever the app chose to add. Here sub names the subject. name is a custom claim. iat records when the token was issued, as a Unix timestamp (seconds since 1 January 1970). That value is 18 January 2018 at 01:30:22 UTC.

Signature

The third part is not JSON. For HS256 it is worked out like this:

HMAC-SHA256(base64url(header) + "." + base64url(payload), secret)

The result is 32 bytes. Base64url turns that into the 43 characters you see. Change one character of the payload and the signature no longer matches. The checker then rejects the token. SHA-256 on its own gives the same 32-byte length. You can see what one looks like by hashing any text in the Hash Generator. HMAC adds a secret key to the hashing. So only someone who knows the key can make a valid result.

Base64url, not quite Base64

JWTs use Base64url. It is a small change to Base64 that makes it safe in URLs and headers. The changes are small, but they matter when you decode by hand:

Standard Base64Base64url
Character 62+-
Character 63/_
Padding= at the end to a multiple of 4Omitted
Safe in a URL as-isNoYes

A part like eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9 decodes in any Base64 tool. It has no - or _ and needs no padding. A signature such as SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c is different. Swap the underscore for a slash and add the padding back. Only then will a standard decoder accept it. The Base64 Encoder and Decoder handles the URL-safe version. The JWT Decoder splits and decodes all three parts in one step. It also shows the expiry as a readable date.

A part that starts with eyJ is a clue. Those are the Base64 bytes of {", the start of a JSON object. If you see eyJ in a log, a cookie or a query string, it is almost surely a JWT. It is also almost surely readable.

Standard claims

RFC 7519 sets aside seven short claim names. All are optional. But a token without exp is a token that never dies.

ClaimMeaningTypeExample
issIssuer: who created the tokenString or URL"https://auth.example.com"
subSubject: who the token is about, usually a user IDString"user_8841"
audAudience: which service the token is forString or array"orders-api"
expExpiry timeUnix seconds1793102400
nbfNot before: reject the token until this timeUnix seconds1793098800
iatIssued atUnix seconds1793098800
jtiJWT ID: a unique ID for the token, used for revoke listsString"c2f1-9a3e"

The time claims are seconds since 1 January 1970 UTC. They are not milliseconds. Say a library writes Date.now() straight into exp. The token then expires around the year 58,000. The Unix Timestamp Converter shows at a glance which unit a value uses. Any other name, such as role, email or tenant_id, is a custom claim. The checker must decide for itself whether to trust it.

Decoding is not verifying

This is the point people most often get wrong. Decoding a JWT means Base64url-decoding two strings. It needs no secret, no key and no network call. That is why a browser tool can do it at once. It tells you what the token says. It tells you nothing about whether the token is genuine.

Verifying means working out the signature again from the exact header and payload, using the correct key. You then compare it, byte for byte, with the third part. Only then do you know the issuer wrote the claims and nobody has edited them. A checker must also test a few more things. The exp time must be in the future. The nbf time must be in the past. The iss must be the issuer you expect. The aud must name your service. A token may pass the signature check but be meant for a different API. You should still reject it.

Anyone can make a token that decodes to {"sub":"admin","role":"superuser"}. Suppose your server reads the payload and acts on it without checking the signature. Then that person is now your superuser. Every JWT library has a decode function and a verify function. Production code must call the second one.

A padlock resting on a laptop keyboard
Photo by Towfiqu barbhuiya on Pexels

HS256 versus RS256

These are the two methods you will meet most often. They differ in who holds the key.

HS256RS256
TypeHMAC with SHA-256, symmetricRSA signature with SHA-256, asymmetric
Signing keyA shared secretA private key
Verifying keyThe same shared secretThe matching public key
Who can mint tokensAnyone who can verify themOnly the holder of the private key
Signature length43 characters342 characters for a 2048-bit key
SpeedVery fastSlower, though still thousands per second
Best fitOne service issuing and using its own tokensOne issuer, many services that use the tokens; third-party identity providers

With HS256, every service that checks tokens must hold the secret. So every one of them can also forge tokens. That is fine inside one app. It is a risk across a dozen small services or any third party. RS256 solves this. The login server keeps the private key. It shares the public key, usually as a JWKS document at a well-known URL. Everyone else can check tokens but cannot sign them. ES256 uses elliptic curve keys. It gives the same safety with much shorter signatures. It is fast becoming the default in new systems.

If you use HS256, the secret must be truly random. It must be at least 32 bytes long. A short, guessable secret can be cracked offline from one captured token. The attacker has everything needed to test guesses at full speed.

Common security mistakes

Secrets or personal data in the payload

The user can read the payload. So can every proxy, and anyone who finds the token in a log. Passwords, API keys, card numbers and anything covered by privacy law do not belong there. Put an ID in the token and look up the rest on the server. Sometimes you truly need private claims. Then use JWE, the encrypted sibling of JWT. Do not hope that nobody decodes a signed one.

No expiry, or an expiry measured in months

A token with no exp is valid forever. You cannot recall it without changing the signing key for everyone. The usual pattern is a short-lived access token of 5 to 60 minutes. It is paired with a refresh token that the server can revoke. Long sessions should come from the refresh token, not from a long exp.

Accepting alg none

The spec allows "alg":"none" for unsigned tokens with an empty signature. Older libraries accepted such a token if the header asked for it. An attacker could strip the signature, change alg and edit the payload at will. Modern libraries refuse none by default. Even so, your code should name the methods it accepts. Do not let the header choose.

Letting the header choose the algorithm

Here is a related attack. A service checks RS256 tokens with a public key. Someone sends a token whose header says HS256. A naive library uses the public key as the HMAC secret. But that key is public. So the attacker can sign with it and get in. The defence is the same as before. The checker picks the method and the key. The token never does.

Skipping the audience and issuer checks

A valid token from your identity provider for the billing dashboard should not open the admin API. Check aud and iss on every request.

Storing tokens where scripts can read them

A token in localStorage can be read by any script on the page, even an injected one. An HttpOnly, Secure, SameSite cookie cannot. If your design allows it, prefer the cookie.

Rows of network equipment in a data centre
Photo by Brett Sayles on Pexels

How to debug an expired token

The error says token expired or jwt expired. Or it simply returns 401. The token looked fine a moment ago. Work through these steps in order.

  1. Decode the token in the JWT Decoder. Read exp, iat and nbf.
  2. Convert the values. Say iat is 1793098800 and exp is 1793102400. The gap is 3,600 seconds, so the token lived for one hour. Some gaps are odd numbers, such as 2,592,000. The Time Converter turns seconds into hours or days, and that one is 30 days. Converting the two values gives 09:00:00 UTC and 10:00:00 UTC on 27 October 2026.
  3. Compare with the server clock in UTC. Do not use your laptop clock or local time. Say the server shows 10:00:05 UTC. The token ended five seconds ago, and the client just needs to refresh. Say the server shows 09:30 UTC and still rejects the token. Then expiry is not the problem. Check nbf, the audience, or whether the signing key was changed.
  4. Check the units. An exp of 1793102400000 is in milliseconds. Many checkers read it as seconds. The token then looks valid for 56,000 years. Others reject it as out of range.
  5. Check for clock skew. Say tokens expire the moment they are issued. Then the issuing and checking servers disagree about the time. Make sure both run NTP, a service that keeps clocks right. Allow a small leeway of 30 to 60 seconds in the checker. Most libraries let you set this.
  6. Check the time zone of whoever made the token. A server may use local time in a zone ahead of UTC. The exp it writes is then already past elsewhere. All JWT times are UTC by rule. The fix belongs in the issuing code.

Example. A support ticket says logins fail every morning for the first few minutes. The decoded tokens show iat values about 90 seconds in the future, as seen from the API server. The clock on the auth server had drifted. The checker applied nbf strictly, so it rejected every token until real time caught up. The team restored NTP on the auth server and set a 60-second leeway. That fixed it.

Where to go next

Paste any token into the JWT Decoder. You will see the header, the claims and a readable expiry without sending the token anywhere. For a single part or an odd token, the Base64 Encoder and Decoder handles the URL-safe version. Use the Unix Timestamp Converter to turn exp and iat into dates. It also helps you spot seconds mixed up with milliseconds. Use the Hash Generator to see what a SHA-256 digest looks like when you think about signature lengths.

Frequently asked questions

Can anyone read a JWT?

Yes. The header and payload are Base64url-encoded JSON. That is an encoding you can reverse, not encryption. So anyone who holds the token can read every claim without a key. The signature only stops people from changing the contents without being caught. Never put passwords, secrets or private personal data in a JWT payload. If you need privacy, use an encrypted format such as JWE.

What is the difference between decoding and verifying a JWT?

Decoding turns the two Base64url parts back into JSON so you can read the claims. It needs no key. Verifying works out the signature again from the header and payload with the right secret or public key. It then compares the result with the token's third part and checks the expiry, issuer and audience. Only a verified token can be trusted. A decoded one just shows what it claims.

Is HS256 or RS256 better?

RS256 is the safer choice when more than one service checks tokens. Those services only need the public key, and they cannot forge tokens with it. HS256 uses one shared secret to sign and to check, so every checker could also make tokens. It is fine for one app that issues and uses its own tokens, if the secret is random and at least 32 bytes long.

How long should a JWT last?

Access tokens usually last 5 to 60 minutes. A signed token cannot be recalled once issued unless you change the key for everyone. Longer sessions are kept alive with a separate refresh token. The server stores that token and can cancel it. A token without an exp claim never expires, which is a serious weakness. So always set one and always check it.

Why does my JWT say it is expired when it was just issued?

The usual cause is clock skew. The server that issues the token and the server that checks it disagree about the time by more than the checker allows. Make sure both run NTP, and allow a leeway of 30 to 60 seconds. Other causes are an exp written in milliseconds instead of seconds, or an issuing server that used local time instead of UTC.

Try the tools from this guide

Keep reading

Related guides

All guides