TestingAPIs is still being built. If something breaks, email support@testingapis.com.

Inside a JWT

Header, claims and signature, and the changes a server must refuse.

A JSON Web Token is a bearer token that carries its own contents: who the user is, what they may do, and when the token stops working, all signed by the server so nobody can change them. The server doesn't need to look anything up to trust it. That makes JWTs fast and popular, and it makes the checks around them important, because a server that skips one trusts whatever the token says.

The JWT format is defined in RFC 7519. This lesson takes a real token from the JWT test APIapart, then sends the changed versions a server must refuse. The REST tutorial's authentication lesson has a shorter introduction if you want one first.

Three parts and two dots

Log in and you get a token back:

Terminal
$ curl -X POST https://api.testingapis.com/api/auth/jwt/login \
  -H "Content-Type: application/json" \
  -d '{"username": "admin", "password": "admin123"}'

{
  "accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJodHRwOi8vc2NoZW1hcy54bWxzb2FwLm9yZy93cy8yMDA1LzA1L2lk…",
  "tokenType": "Bearer",
  "expiresIn": 900,
  "refreshToken": "jrt_68b839b209f1de60d63969e3b87d18c6ebc5ba…",
  ...
}
This example uses endpoints for signed-in members.Sign inCreate a free account

A JWT is three Base64url strings joined by dots: a header, a payload and a signature. The first two are just JSON, encoded so they're safe in a URL. Anyone can decode them, without any key. Paste a token into the JWT decoder, or do it in Node:

JavaScript
const token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJodHRwOi8v…";
const [header, payload] = token.split(".").slice(0, 2)
  .map((part) => JSON.parse(Buffer.from(part, "base64url").toString()));
console.log(header, payload);

The header

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

alg names the signing algorithm. HS256 is HMAC with SHA-256: one secret key, known only to the server, both signs and verifies. The other common family, RS256 and ES256, signs with a private key and verifies with a public one, so other services can check tokens without being able to make them.

The payload

Payload
{
  "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier": "admin",
  "http://schemas.microsoft.com/ws/2008/06/identity/claims/role": "admin",
  "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress": "admin@example.com",
  "jti": "9a5d244d-ec44-430d-88db-255ae3f88442",
  "nbf": 1791716504,
  "exp": 1791717404,
  "iss": "https://api.testingapis.com",
  "aud": "https://api.testingapis.com"
}
This example uses endpoints for signed-in members.Sign inCreate a free account

The fields in a payload are called claims. These are the ones you'll meet everywhere:

ClaimMeaningHere
expExpiry, as a Unix timestamp in seconds15 minutes after the token was issued
nbfNot valid before this timeThe moment of issue
iatIssued atNot present in these tokens
issWho issued the tokenhttps://api.testingapis.com
audWho the token is meant forThe same value
subThe subject, usually a user IDNot present; the user is in nameidentifier
jtiA unique ID for this tokenA random UUID

Two things here are typical of real systems. The username and role use long URI names, because that's how ASP.NET Core writes its standard claim types. A client written against a tutorial that expects "role": "admin"will find no role at all in this token. Read the actual tokens your API issues, then write client code and tests against those, not against what a token "usually" looks like. And check that iss and aud name the real service: development addresses ending up in production tokens are common, and harmless only as long as every service agrees on the same wrong value.

The signature

The third part is what makes the other two trustworthy:

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

Change one character of the header or payload and the signature no longer matches. Only someone with the secret can produce a new one. So the whole security of a JWT rests on the server actually checking the signature, with the right algorithm, on every request. The tests below check exactly that.

Tokens a server must reject

Each of these was sent to /api/auth/jwt/mesign in to see or /api/auth/jwt/adminsign in to see. A correct server refuses every one with 401:

A changed claim

JavaScript
// Take a valid token for "user" and change the role claim to "admin",
// keeping the original signature
const [h, p, s] = userToken.split(".");
const claims = JSON.parse(Buffer.from(p, "base64url").toString());
claims["http://schemas.microsoft.com/ws/2008/06/identity/claims/role"] = "admin";
const forged = [h, Buffer.from(JSON.stringify(claims)).toString("base64url"), s].join(".");
This example uses endpoints for signed-in members.Sign inCreate a free account

Sent to the admin endpoint, the forged token got 401 with error_description="The token signature is invalid." in WWW-Authenticate, and the same message in a JSON body. Check the message as well as the status. ASP.NET Core's default for this case is "The signature key was not found", which sends whoever is debugging off to look at the key when the key is fine.

alg: none

The JWT standard includes an algorithm called none, for tokens that are protected some other way. Some early libraries accepted it on any token, which meant anyone could write their own:

JavaScript
// A token that claims to need no signature at all
const header = Buffer.from(JSON.stringify({ alg: "none", typ: "JWT" })).toString("base64url");
const unsigned = `${header}.${forgedPayload}.`;   // note the empty signature after the last dot

This server answered 401 with The token signature is invalid., as it should. A token with the signature removed but the original header got the same answer.

Everything else

Token sentResponse
The /api/auth/jwt/expiredsign in to see token, correctly signed, expired an hour ago401, error_description="The token expired at 2026-10-11T09:59:12Z."
abc.def.ghi401, The token is invalid.
No token401, with a plain Bearer realm="apibee" challenge
A valid user token, on the admin-only endpoint403, Your token is valid but lacks the role this endpoint requires.

The expired case is the one clients get wrong most often, because it's the one that happens to every user every day. Check that the client sees the 401, refreshes or logs in again, and retries the original request, without showing the user an error. The last row is the role check working: the token is genuine, so the answer is 403, not 401, and the message says what's missing. Many frameworks send that 403 with an empty body by default, and a client that always parses error bodies throws on it.

Expired, but still accepted

"Expires after 15 minutes" isn't quite what happens. A token from this API was sent to /api/auth/jwt/mesign in to see at set times around its exp. It was accepted 60 seconds after expiry, and still accepted 240 seconds after. At 330 seconds it was refused. Servers allow some clock skew, in case their clock and the issuer's disagree, and ASP.NET Core's default allowance is 5 minutes. So a 15-minute token here really lives for about 20. That's rarely a bug, but it matters when a test waits for a token to expire, and when a product promises that access ends at a precise time. Measure it rather than trusting exp.

Algorithm confusion

One more attack has a name you should know, because it has caught real libraries. A server that signs with RS256 publishes its public key. If an attacker sends a token whose header says HS256, a careless library verifies it with HMAC, using the public key as the HMAC secret. The attacker knows the public key, so they can sign anything. The defence is for the server to fix which algorithm it accepts, and never take it from the token. When you test a server that uses RS256 or ES256, send a token signed with HS256 and the public key as the secret, and expect a 401.

JWT test checklist

  • Decode real tokens and confirm every claim is expected and none is secret.
  • Expired, not-yet-valid (nbf in the future), and issued for another aud: all 401.
  • Any byte of the payload changed: 401.
  • alg: none, a missing signature, and for public-key servers an HS256 token: all 401.
  • A valid token without the needed role: 403, not 401 and not 200.
  • How long a token stays usable after its exp, given the server's clock tolerance.

Exercise

Log in as user / user123 and decode the access token. Work out its exact expiry time with the timestamp converter. Then build a forged token with the role changed to admin, and another with exp pushed a day into the future, both keeping the original signature. Send both to /api/auth/jwt/adminsign in to see and /api/auth/jwt/mesign in to see, and record every status and WWW-Authenticate header.

Practise and go further