What is a JWT? JSON Web Tokens Explained
A JSON Web Token (JWT, pronounced "jot") is a compact string that carries a set of claims — statements such as "this is user 42, with the admin role, valid until 15:00" — together with a cryptographic signature that lets the receiver check the claims were issued by someone it trusts and have not been altered. JWTs are the standard token format for API authentication, OAuth 2.0 access tokens and OpenID Connect ID tokens.
This guide explains what is inside a token, what the standard claims mean, how to decode and verify one, and the security rules that separate a safe implementation from a breach.
The three parts of a JWT
A JWT is three Base64URL-encoded segments separated by dots: header.payload.signature. Base64URL is Base64 with URL-safe characters and no padding, so the token can travel in headers, query strings and cookies.
- Header — JSON describing the token type and the signing algorithm, e.g. {"alg": "HS256", "typ": "JWT"}. It may also carry a kid (key id) that tells the verifier which key to use.
- Payload — the claims, as a JSON object. Anything can go here, but it is only encoded, not encrypted: anyone who has the token can read it.
- Signature — computed over the encoded header and payload with the algorithm named in the header. HS256 uses a shared secret (HMAC); RS256 and ES256 use a private key to sign and a public key to verify.
Three Base64URL segments separated by dots: header.payload.signature
Part 1:eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9 .Part 2:eyJzdWIiOiI0MiIsIm5hbWUiOiJMYXlsYSIsInJvbGUiOiJhZG1pbiIsImlhdCI6MTc1NjcwMDAwMCwiZXhwIjoxNzU2NzAzNjAwfQ .Part 3:4l3Y2cYpXqZ0cVZ5m4Q8n1fGx0y8s3oT0kq4rV7cWvA
- 1–2Readable by anyone: decodes to JSON with no key. Encoded, not encrypted.
- 1–2Signed input: the signature is computed over these two encoded segments, so changing either makes verification fail.
- 3Needs the key to check: the shared secret for HS256, the public key for RS256 and ES256.
- 1HeaderThe token type and the signing algorithm; it may also carry a
kid(key id).{"alg":"HS256","typ":"JWT"}Pin the algorithm on the server; never let this header decide - 2PayloadStandard claims
sub(subject),iat(issued at),exp(expiration);nameandroleare custom claims.{"sub":"42","name":"Layla","role":"admin","iat":1756700000,"exp":1756703600}exp − iat = 3600 s: valid for one hour - 3SignatureComputed over the encoded header and payload with the header’s algorithm:
HS256uses a shared secret (HMAC).HMAC-SHA256(secret, "eyJhbGci…" + "." + "eyJzdWIi…")
The page gives no secret for this example, so its signature cannot be checked; it shows where the signature sits.
Text version
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
.eyJzdWIiOiI0MiIsIm5hbWUiOiJMYXlsYSIsInJvbGUiOiJhZG1pbiIsImlhdCI6MTc1NjcwMDAwMCwiZXhwIjoxNzU2NzAzNjAwfQ
.4l3Y2cYpXqZ0cVZ5m4Q8n1fGx0y8s3oT0kq4rV7cWvA
header → {"alg":"HS256","typ":"JWT"}
payload → {"sub":"42","name":"Layla","role":"admin","iat":1756700000,"exp":1756703600}Standard claims and what they mean
- iss (issuer) — who created the token, usually the auth server URL.
- sub (subject) — who the token is about, typically the user id.
- aud (audience) — which service the token is intended for. A verifier must reject tokens meant for another audience.
- exp (expiration) — a Unix timestamp after which the token is invalid. Short lifetimes (minutes for access tokens) limit the damage of a leak.
- nbf (not before) and iat (issued at) — timestamps that bound when the token became valid and when it was minted.
- jti (JWT id) — a unique id, used to detect replay or to revoke a specific token.
- Custom claims — roles, permissions, tenant id, email. Keep them small: the token is sent with every request.
Decoding is not verifying
Decoding a JWT means Base64URL-decoding the header and payload to read them. Anyone can do that with no key at all — which is exactly why you must never trust a decoded payload on its own. Verifying means recomputing the signature with the right key and algorithm and confirming it matches, then checking exp, nbf, iss and aud.
An online decoder is the right tool for reading a token while debugging: seeing which user it names, which roles it carries and when it expires. It is not a security check, and a decoder that runs in your browser is the only kind you should paste a real token into.
HS256 vs RS256 vs ES256
- HS256 (HMAC with SHA-256) — one shared secret both signs and verifies. Simple, fast, fine when a single service issues and consumes the tokens. Every party that can verify can also forge.
- RS256 (RSA signature) — a private key signs, a public key verifies. Any service can verify with the published public key (often via a JWKS endpoint) without being able to mint tokens. The default for OAuth providers.
- ES256 (ECDSA with P-256) — same asymmetric model as RS256 with much smaller keys and signatures.
- none — an algorithm that means "no signature". Verifiers must reject it; accepting alg=none is one of the classic JWT vulnerabilities.
JWT security rules
- Verify the signature and pin the algorithm on the server. Never let the token's own header decide which algorithm to use.
- Always check exp, and check aud and iss against your own values.
- Never put secrets, passwords or sensitive personal data in the payload — it is readable by anyone holding the token. Use JWE if you need encryption.
- Keep access tokens short-lived and use a refresh token to obtain new ones.
- Store tokens carefully in browsers: an HttpOnly, Secure, SameSite cookie is safer than localStorage, which any injected script can read.
- Use a strong secret for HS256 — at least 256 bits of randomness, never a password.
- Plan for revocation: a JWT is valid until it expires, so a logout or a compromised account needs a denylist (jti) or short lifetimes.
JWTs in OAuth 2.0 and OpenID Connect
OAuth 2.0 access tokens are often JWTs, though the spec does not require it; OpenID Connect ID tokens always are. An ID token describes the authenticated user (sub, email, name) for the client application; an access token grants the client permission to call an API (scope, aud). They are verified the same way, but an ID token must never be sent to an API as if it were an access token.
Frequently asked questions
Is a JWT encrypted?
No — a standard signed JWT (JWS) is only Base64URL-encoded. Its payload is readable by anyone who has the token. Encrypted tokens exist (JWE) but are much less common.
Can a JWT be decoded without the secret?
Yes. The header and payload are plain Base64URL, so any decoder can read them. The secret or key is only needed to verify the signature, which is what proves the token is authentic.
How do I check if a JWT has expired?
Decode it and compare the exp claim (a Unix timestamp in seconds) with the current time. A verifier library does this automatically; an online decoder shows the expiry as a readable date.
What is the difference between a JWT and a session cookie?
A session cookie is an opaque id that the server looks up in its own store; a JWT carries the session data itself, signed, so the server needs no lookup. JWTs scale across services more easily; sessions are simpler to revoke.
Where should I store a JWT in a web app?
Prefer an HttpOnly, Secure cookie with SameSite set, so JavaScript on the page cannot read it. localStorage is convenient but exposes the token to any cross-site scripting bug.
Is it safe to paste a JWT into an online decoder?
Only if the decoder runs entirely in your browser and never sends the token anywhere. The Mutqan JWT decoder works locally; even so, treat production tokens as secrets and prefer test tokens when you can.
References
- RFC 7519: JSON Web Token (JWT)Defines the JWT format — header, payload, signature — and the registered claims iss, sub, aud, exp, nbf, iat and jti.
- RFC 7515: JSON Web Signature (JWS)How a JWT is signed and serialized: the protected header, the signing input and the compact serialization.
- RFC 8725: JSON Web Token Best Current PracticesThe security rules for JWT implementations: reject alg=none, pin algorithms, validate iss/aud, use short lifetimes.
- OWASP Cheat Sheet Series: JSON Web TokenPractical JWT hardening: algorithm confusion, token sidejacking, storage in the browser, revocation.
Practice exercise
Review a banking app’s access token and its verification tests
You test the online banking web app of a retail bank (fictional) and the Accounts API it calls. Build 4.2.0 is on the staging environment. The material below contains the team’s token specification, which is the test basis (T1–T7); an access token you copied from the browser after signing in as a test customer; the results of eight verification tests a teammate ran against the Accounts API; and a question from the front-end developer.
Decode the token, check it against the specification, judge every test result and answer the developer. Allow about 30 minutes. The JWT Decoder shows the header and the payload, with iat, nbf and exp as UTC dates (copy only the seven token lines; the line breaks are ignored). Once you have formed your own view, the JWT Claims Analyzer and the JWT Header Analyzer flag risky claims and header values; they judge against general practice, not against T1–T7, so they can accept something the specification forbids. If you work on paper, the decoded token is in the first paragraph of the model answer.
Changing a token as in V2 and V3 is a test you run only on your own team’s systems, or with written permission from the system’s owner.
Online banking of a retail bank (fictional) - web app and Accounts API
Build 4.2.0 on staging. Accounts API: https://api.staging.bank.example/v1/accounts
Token specification (the test basis)
T1 The auth server signs access tokens with ES256. The Accounts API accepts ES256
only and takes the public key from the auth server’s JWKS, chosen by kid.
T2 iss must be https://auth.staging.bank.example and aud must be accounts-api.
T3 An access token lives 10 minutes (exp = iat + 600); the web app renews it
with a refresh token.
T4 The access token carries only iss, sub (customer number), aud, iat, nbf, exp,
jti and scope. No personal data.
T5 Signing out puts the token’s jti on a denylist that the API checks.
T6 The web app keeps the access token in an HttpOnly, Secure, SameSite cookie,
never in localStorage.
T7 The API answers every token that fails verification with 401.
Access token of test customer C-100482, copied on 30 September 2026 right after
sign-in, from the browser’s localStorage (key: access_token):
eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6InN0Zy0yMDI2LTA5In0.eyJpc3MiOiJodHRwczov
L2F1dGguc3RhZ2luZy5iYW5rLmV4YW1wbGUiLCJzdWIiOiJDLTEwMDQ4MiIsImF1ZCI6ImFjY291bnRzLWFw
aSIsImlhdCI6MTc5MDc0ODAwMCwibmJmIjoxNzkwNzQ4MDAwLCJleHAiOjE3OTA4MzQ0MDAsImp0aSI6Ijdk
MmYwYzllLTRiMWEtNGU4ZS05YTNkLTVjNmIyZjFlOGE0MCIsInNjb3BlIjoiYWNjb3VudHM6cmVhZCB0cmFu
c2ZlcnM6d3JpdGUiLCJyb2xlIjoiY3VzdG9tZXIiLCJuYW1lIjoiU2FyYSBBbC1IYXJiaSIsIm5hdGlvbmFs
X2lkIjoiMTIzNDU2Nzg5MCJ9._rh027IPcSrasUBWj1c2UiLI8IbEc7oMm7nIwMXaSc7Mt0yf3TWbGdoASKU
OItR_TDURrtVHr0XfGXNLMb9u-A
Verification tests on GET /v1/accounts, run by a teammate on staging
V1 the copied token, unchanged, within its lifetime
-> 200 OK accounts of C-100482
V2 payload edited: sub changed to C-100483; header and signature unchanged
-> 401 Unauthorized
V3 header changed to {"alg":"none","typ":"JWT"}, sub changed to C-100483,
signature removed (the token ends with a dot)
-> 200 OK accounts of C-100483
V4 a token whose exp passed 40 seconds earlier
-> 200 OK accounts of C-100482
V5 a token whose exp passed 15 minutes earlier
-> 401 Unauthorized
V6 an access token the auth server issued for the Cards API (aud: cards-api)
-> 200 OK accounts of C-100482
V7 the web app’s OpenID Connect ID token (aud: web-banking-app; it carries
name and email, and no scope)
-> 200 OK accounts of C-100482
V8 the copied token, sent again after the customer signed out
-> 401 Unauthorized
Question from the front-end developer
"The page header shows the customer’s name, and we hide the Transfers menu when
scope has no transfers:write. We read both from the access token in JavaScript.
Can name and national_id stay in the token?"- Decode the token and write down
iat,nbfandexpas UTC dates. From 06:00 UTC on 1 October 2026 the decoder shows the token as expired; judge its lifetime, not today’s status. - Check the header, every claim and the place where the token was stored against T1–T7. Mark each item correct, defect or question for the team, and name the rule it breaks.
- For V1–V8, write the expected result from the specification before you read the actual result. Then mark each row correct, defect or question, and group the defects that share one root cause.
- List at least three verification tests that the log does not contain yet.
- Answer the developer’s question.
- Choose the finding you would report first, write the title of its defect report and give the reason for its priority.
Show the model answer
The decoded token. Header: {"alg":"ES256","typ":"JWT","kid":"stg-2026-09"}. Payload: iss https://auth.staging.bank.example, sub C-100482, aud accounts-api, iat and nbf both 1790748000 (30 September 2026, 06:00:00 UTC), exp 1790834400 (1 October 2026, 06:00:00 UTC), jti 7d2f0c9e-4b1a-4e8e-9a3d-5c6b2f1e8a40, scope accounts:read transfers:write, role customer, name Sara Al-Harbi, national_id 1234567890. The signature segment has 86 characters, which is 64 bytes: the size of an ES256 signature.
Summary: five defects (the lifetime, the personal data, the storage, the none algorithm in V3 and the unchecked audience in V6 and V7), three questions for the team (role, the leeway in V4 and, raised by V7, whether the API checks scope), and four rows where the API behaves correctly (V1, V2, V5, V8). Decoding needed no key at all, which is the guide’s central point: everything in the payload can be read by anyone who holds the token.
Header, iss, aud, sub, jti and scope — correct. alg is ES256 with a kid, as T1 requires, and iss and aud match T2. nbf equal to iat is normal: the token is valid from the moment it was issued. jti is what makes the denylist in T5 possible.
Lifetime — defect (T3). exp - iat is 86400 seconds, that is 24 hours instead of 600 seconds (10 minutes), so a leaked token can be used for a whole day instead of a few minutes. A general-purpose claims analyzer may call a 24-hour lifetime acceptable; here the test basis is T3, and T3 says 10 minutes. Severity major. Expected result: exp = iat + 600. After the fix, also check that a token is refused soon after its 10 minutes (plus the leeway from V4) and that the web app renews it with the refresh token without signing the customer out.
name and national_id — defect (T4). The payload is encoded, not encrypted: you have just read a national ID without any key, and so can anyone who sees the token in browser storage, a log, a proxy or a screenshot attached to a bug report. The token also travels with every request. Severity major; the bank’s data protection rules may rate it higher. Expected result: the payload holds only the claims T4 lists. This rule is about the access token; the ID token is meant for the web app and may carry the name (see the developer’s question).
role — question for the team. T4 does not list it, so either the specification is out of date or the claim should go. A role inside a signed token is not a security problem in itself, because the signature protects it, but only if the API verifies the signature with a pinned algorithm, and V3 shows that today it does not: with none, anyone can write any role.
Storage — defect (T6). The token sits in localStorage under the key access_token, where any script running on the page can read it, including a script injected through a cross-site scripting (XSS) bug. With a 24-hour lifetime, a stolen token keeps working for a day unless the customer signs out (V8 shows that signing out does revoke it). Severity major. Expected result: an HttpOnly, Secure, SameSite cookie. When the fix arrives, paste its Set-Cookie header into the Cookie Analyzer to confirm the three attributes, and check that localStorage no longer holds the token.
V1 — correct: 200 with the customer’s own accounts. V2 — correct: 401. Changing the payload breaks the signature, and the API notices. This is the difference between decoding and verifying: anyone can edit a decoded payload, but only the auth server’s private key can produce a signature that matches it.
V3 — defect, critical (T1, T7). Expected result: 401, because T1 allows ES256 only. The 200 with customer C-100483’s accounts is the failure; the defect behind it is that the API lets the token’s own header choose the algorithm, and with none it checks no signature at all. Together with V2 this shows that verification works when the header says ES256 and is switched off when the header asks for it. Anyone who knows or guesses a customer number can read that customer’s accounts, and can write any other claim too, such as a scope that includes transfers:write.
V4 — question. RFC 7519 allows a verifier a small leeway for clock differences between servers, usually no more than a few minutes, and T3 says nothing about it. V5 shows that exp is checked, so the leeway lies somewhere between 40 seconds and 15 minutes. Ask the team for the configured value and have it added to the specification; then test the boundary with two values: a token used just inside the leeway is accepted, and one used one second after it ends gets 401. If the leeway turns out to be longer than a few minutes, report it as a defect, because it extends the life of every token. V5 — correct: 401.
V6 — defect (T2). Expected result: 401, because aud is cards-api, not accounts-api. A token issued for one service opens another, so a token leaked from the Cards API, or from any service that receives such tokens, also opens customers’ accounts. V7 — defect with the same root cause: an ID token is meant for the web app, not for an API, and the guide says it must never be used as an access token. Report V6 and V7 together as one defect, "The Accounts API does not check aud", with both rows as examples; two reports would describe the same root cause twice. Severity major (some teams rate it critical). V7 also raises a question: the ID token has no scope at all and still returned the account list, so ask whether the API checks accounts:read, and add a test for it.
V8 — correct: 401 after sign-out, so the jti denylist from T5 works. It does not make a 24-hour lifetime harmless: a customer who never signs out leaves a token that stays valid for a day.
Verification tests the log does not contain yet (any three are enough): the header changed to HS256 and the token signed with an HMAC secret of your choice, for example in the JWT Generator, expected 401, and the API log should show that it was refused for its algorithm, because the server, not the header, must choose the algorithm; none in other letter case, such as None and NONE, after the fix, because a check that blocks only the exact string none can miss them; a token signed with your own ES256 key instead of the auth server’s, expected 401, because only keys from the JWKS may be trusted; a token from the development environment’s auth server (another iss and another key) and a token whose nbf lies in the future, both expected 401; an access token whose scope lacks accounts:read, expected a refusal (RFC 6750 recommends 403 for insufficient scope; confirm the code with the team); and the leeway boundary from V4. Tokens with chosen claims and a valid ES256 signature need the auth team’s help on staging, for example a test client that issues them; ask for it rather than skipping those tests.
The developer’s question: no for national_id, and not in this form for name. Personal data does not belong in the access token (T4). The ID token is the token meant for the web app and it already carries the customer’s name (see V7), so the page can show the name from the ID token or from an endpoint that returns the customer’s profile and permissions; national_id belongs in neither token. Reading the access token in JavaScript also works only because the token sits in localStorage: once it moves into an HttpOnly cookie as T6 requires, page scripts cannot read it at all, so the design has to change anyway. Hiding the Transfers menu is fine as a convenience, but it is not a security control: decoding is not verifying, so the API must check transfers:write on every transfer request. Add a test for that: a transfer request with a token whose scope has no transfers:write must be refused.
Report V3 first. Title, for example: "Accounts API accepts unsigned tokens with alg: none; changing sub returns another customer’s accounts". It comes first because it needs no stolen token and no key, only a customer number, and it exposes every customer’s accounts: severity critical, highest priority. Because it is a security defect, tell the developers straight away and follow your team’s process for security issues, for example a ticket with restricted access. Then the audience defect (V6 and V7); then the 24-hour lifetime and the storage in localStorage, which together decide how long and how easily a stolen token can be used; then the personal data in the payload. If your team ranks the national ID first because the bank’s data protection rules treat it as an incident, that is a sound reason; what matters is that the priority follows the impact. After each fix, rerun the affected rows as confirmation tests, and rerun V1, V2, V5 and V8 as regression tests, because a change in the verification code can break what works today.
To check your work: you should have five defects and none in V1, V2, V5 or V8. If you marked V2 as a defect, reread "Decoding is not verifying" in the guide: a 401 for an edited payload is exactly what a verifier must do. If you marked nbf equal to iat as a problem, reread the section on claims: nbf only bounds when the token became valid.
Your progress on this topic
Not started
Mark the topic complete when you have studied it and done the exercise.
Sign in to track your progress
Mark topics complete, continue where you left off and unlock specialisation exams.
Part of the QA Roadmap
Before this topic
Next topics
Tools mentioned in this guide
Decode JWT header and payload, inspect claims and check expiration — entirely in your browser.
Create signed JWTs (HS256 / HS384 / HS512) from custom claims for testing.
Analyze JWT claims for missing, risky or non-standard values and get security recommendations.
Check whether a JWT is expired, not yet valid, or how long remains until expiry.
Inspect the JOSE header of a JWT: alg, typ, kid, jku, x5u and potential algorithm-confusion risks.
Decode OAuth 2.0 / OpenID Connect access and ID tokens and understand scopes, audiences and lifetimes.
Generate HMAC signatures (SHA-256/384/512/SHA-1) for webhook and API request signing.
Decode Base64 or Base64url strings to text, with automatic padding repair.