On this page 10 sections
- A token is a statement, not a session magic bean
- JWS versus JWE, and when encryption is needed
- Claims: iss, aud, exp, and the ones you should add
- JWKS, kid and rotating keys
- Verification steps a server must not skip
- Access tokens, refresh tokens and storage on a client
- The usual ways this goes wrong
- How to inspect a token without trusting it
- Key takeaways
- Frequently asked questions
I keep a JWT Decoder, a JWT Encoder, a JWT & JWKS Signature Verifier and a JWE Decrypter on this site because I needed a place to inspect tokens without pasting them into a stranger’s webpage. What follows is the verification-first picture of how a small API should treat them.
A token is a statement, not a session magic bean
A compact JWT is three Base64URL segments, separated by dots: a header, a payload, and a signature (for the signed form). The payload is a JSON object of claims. The signature (or the encryption layer) is the only reason a receiver should believe those claims.
That leads to a rule you should treat as non-negotiable:
Never use the payload of a token you have not verified. Do not read sub, admin, exp or anything else from a decoded-but-unverified string, and do not write your own parser that “just splits on the dots” and JSON.parses the middle. A token with the algorithm none, or with a signature you did not check, is a user-controlled JSON blob.
A session cookie, by contrast, is usually an opaque pointer to state the server holds. A JWT is a statement the server has to check, and then may trust until it expires. If you need to revoke access immediately, a signed token on its own is a poor fit unless you also keep a block-list, a short expiry, or a server-side session. Those are product choices, not something the token format solves for you.
On an iOS client, the same caution applies: store tokens carefully, don’t log them, and don’t parse them to decide permissions the server has not granted. That habit sits alongside the other privacy work in privacy manifests and App Store review.
JWS versus JWE, and when encryption is needed
People say “JWT” when they mean at least two things:
- JWS (JSON Web Signature): the common, three-part token. The payload is signed, but it is not encrypted. Anyone who can see the token can read the claims. They cannot alter them without breaking the signature, if the receiver verifies correctly.
- JWE (JSON Web Encryption): a five-part token whose payload is encrypted, and usually also integrity-protected. Only someone with the decryption key can read the claims.
If the claims are not sensitive — a user id, an expiry, a list of scopes the bearer is allowed to present — JWS is the usual choice, sent over HTTPS. HTTPS already encrypts the token in transit. The remaining risk is leftover tokens in logs, in browser history, in crash reports, and on a lost device. Don’t put a national insurance number, a password, or a card number in a JWS payload; the signature does not hide it.
Use JWE when the token will pass through a party that should not be able to read the claims, or when the payload itself is confidential even at rest with the client. That is a smaller set of designs than marketing language sometimes suggests. If you do use JWE, you still have to authenticate the token — encryption without integrity is not enough — and you should use a library that implements a modern combination, not a homemade XOR.
The JWE Decrypter & Encrypter is for inspecting a JWE you already have the key for, in a tool you run yourself. Don’t paste production tokens or keys into a website you do not control, including mine, if the token is still live.
Claims: iss, aud, exp, and the ones you should add
After the signature verifies, the claims are what you actually check. The registered names you should almost always treat as mandatory for an access token are:
iss(issuer): who minted the token. Compare it to an exact value you configured, such ashttps://auth.example.com. Don’t accept “any issuer we have ever trusted.”aud(audience): who the token is for. Your API should have a name or URL, and it should reject tokens whoseauddoes not include that name. A token minted for another service, even by the same issuer, is not a token for you.exp(expiry): a Unix timestamp after which the token must be rejected. Allow a small clock skew, measured in seconds or a minute, not in hours.nbf(not before), if present: reject the token until that time, again with a small skew.iat(issued at), if present: useful for debugging and for rejecting implausibly old tokens even whenexpis still in the future, if that is your policy.sub(subject): the stable identifier of the user or service. Treat it as an identifier, not as a display name, and not as a place to stuff an email address you then never verify.
Add your own claims sparingly, with names you control. A small API might include a cid for the client application, or a scope string. Don’t put a long, mutable authorisation matrix in the token if you will regret not being able to change it until expiry. Permissions that can be withdrawn should live on the server, or the token should be short-lived.
A decoded, unverified payload might look like this (and until you verify it, it is fiction):
{
"iss": "https://auth.example.com",
"aud": "https://api.example.com",
"sub": "user_123",
"exp": 1731261600,
"iat": 1731258000,
"scope": "tools:read"
}The JWT Decoder will show you those fields. The decoder is a flashlight, not a gate. The JWT & JWKS Signature Verifier is the gate.
JWKS, kid and rotating keys
A JWKS (a JSON Web Key Set) is a JSON document, usually published at a stable HTTPS URL, that lists the public keys an issuer currently uses to sign tokens. A typical URL looks like https://auth.example.com/.well-known/jwks.json, or the jwks_uri advertised in an OpenID Provider configuration document.
A set looks like this:
{
"keys": [
{
"kty": "RSA",
"use": "sig",
"alg": "RS256",
"kid": "2026-09-key",
"n": "…",
"e": "AQAB"
}
]
}The token header usually includes a kid (key id) that tells the receiver which key in the set to use. That is a hint, not a command. The receiving server should:
- Fetch and cache the JWKS over HTTPS, from a URL it was configured with, not from a URL in the token.
- Pick a key that matches the
kid, the expected key type, and the expected algorithm. - Verify the signature with that key.
- If the
kidis unknown, refetch the JWKS once (keys rotate), and if the key is still unknown, reject the token. - Never “helpfully” fall back to any other key that happens to be in the cache, and never accept a key that is embedded in the token header (
jwkor ax5cyou did not pin).
Rotation is why JWKS exists. A careful issuer:
- publishes a new key in the JWKS first
- starts signing new tokens with the new
kidonly after the new public key has been visible for long enough that receivers have refreshed - keeps the old public key in the set until every token signed with it has expired
- then removes the old key
A receiver should cache the JWKS for a short time (minutes, not days) and honour cache headers with a cap, so a rotation is picked up. If you pin a single PEM in a config file and never refresh it, you will drop every user on the next rotation, or you will be unable to retire a key.
Symmetric algorithms (HS256 and friends) do not use a public JWKS in the same way: the issuer and the API share a secret. That is workable on a single server you control, and fragile as soon as more than one service needs to verify. Don’t advertise an HMAC secret in a JWKS as if it were a public key.
Verification steps a server must not skip
A library will do the cryptography; you still have to configure it. The sequence I want to see in a small API is:
- Parse the compact token as a token, rejecting strings with the wrong number of parts, or with a
typyou do not accept, before you think about claims. - Read the header only far enough to learn
algandkid. Comparealgto an allow-list you configured (RS256,ES256, and so on). Rejectnone. Reject an algorithm switch: if the key is an RSA public key, do not verify an HMAC “signature” with it. This is a well-known class of bug. - Resolve the key from your configured JWKS or secret, as above, not from the token.
- Verify the signature (or decrypt and verify a JWE).
- Check
iss,aud,exp, andnbf. - Only then read
suband your own claims, and make the authorisation decision.
Do this in a well-tested library for your language. The JWT Encoder & Signer is useful for making a fixture in a test; it is not a reason to sign tokens in a browser with a private key you pasted into a form.
Time is part of verification. Servers should run NTP or an equivalent, because a clock that is ten minutes off will mint or reject tokens incorrectly. Clients should not be asked to check exp as the only security control; they can use it to decide when to refresh, but the API must check it again.
Access tokens, refresh tokens and storage on a client
An access token is what the client sends to the API, usually as Authorization: Bearer …. It should be short-lived. A refresh token is a longer-lived credential for getting a new access token from the authorisation server, not for calling the API.
On a server-side web app, both can live in HTTP-only, Secure, SameSite cookies, or the access token can be kept in memory and the refresh token in a cookie. On a single-page app, the least-bad default that is still common is to keep tokens out of localStorage if you can, use the OAuth 2.0 Authorization Code flow with PKCE, and let a small backend hold the refresh token. The OAuth 2.0 PKCE Flow Simulator walks through the PKCE dance; it is the right shape for a public client, including a native app.
On iOS, use the system browser or ASWebAuthenticationSession for the sign-in, store refresh tokens in the Keychain, and attach the access token to requests you already send carefully — the same family of work as retries with URLSession, often sitting alongside Swift concurrency when those requests are awaited. Don’t put a bearer token in a URL query, and don’t write it into application logs.
If you accept an access token, send it only over HTTPS, and don’t put it in a hidden form field that might be logged. If it leaks, it is valid until it expires, unless you have a revocation list. That is why short exp values and refresh are worth the extra request.
The usual ways this goes wrong
The failures I would look for in a review, before any talk of clever claims:
- Decode without verify, including in a debug middleware that later became the authorisation check.
algconfusion andnone. An allow-list of algorithms, tied to the key type, is the defence.- Accepting a key from the token (
jwk,x5u, or an unpinned certificate chain). - Skipping
audoriss, so a token for another of your services, or a public example token, is accepted. - Long-lived access tokens with a permission you might need to withdraw.
- Putting secrets or personal data in a JWS, then logging the token.
- A JWKS fetched over HTTP, or from a URL the attacker can influence.
- No rotation plan, so a leaked signing key is valid forever, or a planned rotation logs everyone out.
- Using a JWT as a session and then discovering you cannot log a stolen laptop out.
A token that “looks fine” in a decoder has not been checked. Treat the decoder’s output as a comment, and the verifier’s output as the result.
How to inspect a token without trusting it
When a request fails with 401, or a mobile client claims it is signed in but the API disagrees, inspect the token in a controlled way:
- Copy the compact string from the client only if you can treat it as compromised, or if it is a test token. Prefer a fixture generated with the JWT Encoder.
- Decode the header and payload with the JWT Decoder to see
alg,kid,iss,audandexp. This step explains; it does not approve. - Verify against the issuer’s JWKS or your test key with the JWT & JWKS Signature Verifier. If this fails, stop. The claims are untrusted.
- Compare the claims to what the API is configured to accept. A valid signature on a token for a different
audshould still be a 401. - If the token is a JWE, decrypt it only in a tool or environment that already has the key, such as the JWE Decrypter run with a key you supplied, and then run the same claim checks.
Don’t paste a live production refresh token into a SaaS debugger. Mint a short-lived test token, or use logs that already redacted the string. Once a bearer token has been through a chat window, rotate it.
Key takeaways
- A JWT is a statement you must verify. Decoding is not verification, and the payload is untrusted until the signature and the claims checks have both passed.
- JWS hides nothing; it only detects alteration. Use HTTPS, keep secrets out of the payload, and use JWE only when a passer-by must not be able to read the claims.
- Always check
iss,audandexp, with an algorithm allow-list and a key that came from your configuration or JWKS, not from the token. - Publish new keys before you sign with them, keep old public keys until expiry, and cache the JWKS briefly enough to notice a rotation.
- Keep access tokens short-lived, store refresh tokens as carefully as passwords, and inspect production tokens only in a way you would accept having to revoke.
If you want a second pair of eyes on an API’s token checks, tell me about it.
Frequently asked questions
What is the difference between a JWT and a JWKS URL?
A JWT is a token: a signed or encrypted set of claims, sent with a request. A JWKS URL is where the issuer publishes the public keys a server should use to verify those tokens. You configure the JWKS URL; you do not take it from the token.
Should I store permissions inside a JWT?
You can store coarse, slow-changing scopes in the token, if you are willing to wait until expiry (or to revoke) when they change. Permissions you might withdraw immediately belong on the server, or the access token should be short-lived and refreshed often.
How do I rotate signing keys without dropping users?
Add the new public key to the JWKS first, wait until verifiers have refreshed, start signing new tokens with the new kid, and only remove the old key after every token it signed has expired. Receivers should refetch the JWKS when they see an unknown kid.
What is the difference between JWS and JWE?
JWS signs the claims but leaves them readable. JWE encrypts the claims so only a holder of the key can read them. Most API access tokens are JWS, sent over HTTPS. Encryption is an extra step for a payload that must stay confidential from intermediaries.
Why is it unsafe to decode a JWT without verifying it?
Because the payload is just Base64-encoded JSON. Anyone can write a header and a payload and omit a valid signature, or use the none algorithm. If your code trusts admin: true from a decoded token, an attacker can send you that JSON and you will believe it.
Written by Lee Johnson. Published 10 November 2026.