JWT Token Testing: Structure, Expiry, Tampering, Refresh and Logout
JWTs (JSON Web Tokens) keep you logged in to almost every modern app, from food delivery to banking. They also cause some of the most serious API vulnerabilities: tokens that never expire, signatures that aren’t checked, and logouts that don’t really log you out. In this lesson you’ll take a real JWT apart, then test expiry, tampering, refresh rotation and logout on the live practice API, manually and with Playwright.
sub, role, exp) and signature. To test JWT authentication, verify that a valid token works, and that a missing, malformed, wrongly-schemed, tampered, expired or revoked token returns 401; that refresh tokens rotate and can be used only once; that the payload holds no secrets; and that the server rejects the alg: none trick.What Is a JWT? (The Cinema Wristband Example)
Because the server can verify the signature with its secret key, it doesn’t need to look up a session in a database on every request. That’s why JWTs are called stateless tokens.
Anatomy of a Real JWT
Log in to the practice API and you get an access_token like this (shortened):
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJodHRwczpcL1wvc3FhdG9vbHMuaW5cLyIsInN1YiI6ImFkbWluIiwi...fQ._ZDcTiyS-183vQ2eAI_zeSnPgZMdX2Z3IYBrifVfyiM
<------------- header ------------->.<-------------------- payload -------------------->.<-------- signature -------->
Decoding the first two parts (Base64URL) shows:
// Header
{ "alg": "HS256", "typ": "JWT" }
// Payload (claims)
{
"iss": "https://sqatools.in/", // issuer
"sub": "admin", // subject: the user
"name": "Admin User",
"role": "admin", // custom claim used for authorization
"iat": 1794482821, // issued at (Unix seconds)
"exp": 1794483721, // expires at: iat + 900 s
"jti": "2hqtst3pmbas36wm" // unique token id (used for revocation)
}
The third part is the signature: HMACSHA256(base64url(header) + "." + base64url(payload), secret). Change one character of the header or payload and the signature no longer matches.
Standard claims to check
| Claim | Meaning | What to test |
|---|---|---|
exp | Expiry time | Exists; short (minutes, not months); enforced |
iat / nbf | Issued at / not before | Token used before nbf is rejected |
iss | Issuer | Tokens from another issuer are rejected |
aud | Audience | A token for API A doesn’t work on API B |
sub | User | Server uses it, not a user id sent in the body |
jti | Token id | Supports revocation and replay detection |
The Token Lifecycle on the Practice API
| Step | Request | Result |
|---|---|---|
| 1. Log in | POST /auth/login {"username":"admin","password":"admin@123"} | 200: access_token (900 s), refresh_token, user |
| 2. Use | GET /auth/me + Authorization: Bearer <access> | 200: user, role, can_write |
| 3. Refresh | POST /auth/refresh {"refresh_token":"..."} | 200: new access and new refresh token |
| 4. Reuse old refresh | Same refresh token again | 401 invalid_refresh_token (rotation) |
| 5. Log out | POST /auth/logout + Bearer | 204 |
| 6. Use after logout | GET /auth/me with the old token | 401 token_revoked |
Start by logging in here, then copy the access_token into Postman or the cURL commands below:
https://sqatools.in/wp-json/sqa-api/v1/auth/logincurl -i \
-X POST \
-H "Content-Type: application/json" \
-d '{"username":"admin","password":"admin@123"}' \
"https://sqatools.in/wp-json/sqa-api/v1/auth/login"You can also request a shorter lifetime for expiry testing: send "expires_in": 5 in the login body (allowed range 5 to 3600 seconds; 3 returns 422).
JWT Test Cases (Complete List)
| # | Scenario | How | Expected |
|---|---|---|---|
| 1 | Valid token | Bearer + fresh token | 200 |
| 2 | No token | No Authorization header | 401 |
| 3 | Garbage token | Bearer abc.def | 401 |
| 4 | Wrong scheme | Token <jwt> | 401 |
| 5 | Tampered payload | Change "role":"viewer" to "admin", keep signature | 401 (signature invalid) |
| 6 | Tampered signature | Change one character of part 3 | 401 |
| 7 | alg: none attack | Header {"alg":"none"}, empty signature | 401 |
| 8 | Expired token | Login with expires_in: 5, wait 7 s | 401 |
| 9 | Lifetime boundary | expires_in: 4 / 5 / 3600 / 3601 | 422 / 200 / 200 / 422 |
| 10 | Refresh works | POST /auth/refresh | 200, new pair |
| 11 | Refresh reuse | Same refresh token twice | Second: 401 |
| 12 | Refresh missing | Empty body | 422 |
| 13 | Logout revokes | Use token after /auth/logout | 401 |
| 14 | Token from another environment | Staging token on production | 401 (different secret/issuer) |
| 15 | No secrets in payload | Decode the token | No password, PII limited |
| 16 | Token not in URL / logs | Inspect requests and logs | Header only |
Testing Tampering Step by Step
"role":"viewer", changes it to "admin", re-encodes the payload and sends it. If the server trusts the payload without verifying the signature, a read-only user just became an admin. This exact class of bug has been found in real production systems.- Log in as
viewer/viewer@123and copy the token. - Split it at the dots. Base64URL-decode the middle part.
- Change
"role":"viewer"to"role":"admin", encode it again (Base64URL, no padding). - Rebuild
header.newPayload.originalSignatureand callGET /auth/me. - Pass if the response is
401(“Token signature is not valid”). Critical bug if it returns 200 with role admin.
# Quick signature tamper with cURL (replace TOKEN). Changing the last 2 characters breaks the signature.
TAMPERED="${TOKEN%??}AA"
curl -i https://sqatools.in/wp-json/sqa-api/v1/auth/me -H "Authorization: Bearer $TAMPERED"
# HTTP/1.1 401 ... "Token signature is not valid (was the token changed?)."
Testing Expiry Without Waiting 15 Minutes
Real access tokens may live 15 to 60 minutes. Waiting is slow, so testers use one of these:
- A test-only short lifetime, like the practice API’s
expires_infield. - A configuration flag in the test environment that sets token lifetime to seconds.
- An already-expired token saved from a previous run (works as long as the signing key hasn’t changed).
- Unit tests on the token validator with a fake clock (developer level).
Also test clock skew: servers usually allow 30 to 60 seconds of tolerance; a token expired an hour ago must fail.
Refresh Tokens and Rotation
Short access tokens are safer, but users don’t want to log in every 15 minutes. A refresh token (long-lived, stored securely) gets new access tokens silently. With rotation, every refresh returns a new refresh token and invalidates the old one. If an attacker steals a refresh token and the real app also uses it, the reuse is detected.
Refresh tests: refresh works once; reuse returns 401; a refresh token can’t be used as an access token (and vice versa); logout and password change revoke refresh tokens; refresh tokens have their own maximum lifetime.
JWT Testing in Postman
- Send
POST /auth/login. In the Scripts → Post-response tab add:pm.test('login ok', () => pm.response.to.have.status(200)); const body = pm.response.json(); pm.environment.set('access_token', body.access_token); pm.environment.set('refresh_token', body.refresh_token); - On
GET /auth/me, set Authorization → Bearer Token to{{access_token}}. - Duplicate the request, change the token to
{{access_token}}xand assert 401.
Full Postman scripting is in Postman Tests and Newman.
JWT Tests in Playwright (TypeScript)
import { test, expect, APIRequestContext } from '@playwright/test';
const BASE = 'https://sqatools.in/wp-json/sqa-api/v1';
async function login(request: APIRequestContext, user = 'admin', pass = 'admin@123', expires_in?: number) {
const res = await request.post(`${BASE}/auth/login`, { data: { username: user, password: pass, expires_in } });
expect(res.status()).toBe(200);
return res.json() as Promise<{ access_token: string; refresh_token: string; expires_in: number }>;
}
const bearer = (t: string) => ({ Authorization: `Bearer ${t}` });
const decode = (t: string) => JSON.parse(Buffer.from(t.split('.')[1], 'base64url').toString());
test('payload has expected claims and no secrets', async ({ request }) => {
const { access_token } = await login(request);
const claims = decode(access_token);
expect(claims).toMatchObject({ sub: 'admin', role: 'admin', iss: 'https://sqatools.in/' });
expect(claims.exp - claims.iat).toBe(900);
expect(JSON.stringify(claims)).not.toContain('admin@123');
});
test('forged role in payload is rejected', async ({ request }) => {
const { access_token } = await login(request, 'viewer', 'viewer@123');
const [h, p, s] = access_token.split('.');
const forged = Buffer.from(JSON.stringify({ ...decode(access_token), role: 'admin' })).toString('base64url');
const res = await request.get(`${BASE}/auth/me`, { headers: bearer(`${h}.${forged}.${s}`) });
expect(res.status()).toBe(401);
});
test('expired token is rejected', async ({ request }) => {
test.setTimeout(30_000);
const { access_token } = await login(request, 'admin', 'admin@123', 5);
await new Promise(r => setTimeout(r, 7_000));
expect((await request.get(`${BASE}/auth/me`, { headers: bearer(access_token) })).status()).toBe(401);
});
test('refresh token rotates and works only once', async ({ request }) => {
const { refresh_token } = await login(request);
expect((await request.post(`${BASE}/auth/refresh`, { data: { refresh_token } })).status()).toBe(200);
expect((await request.post(`${BASE}/auth/refresh`, { data: { refresh_token } })).status()).toBe(401);
});
test('logout revokes the access token', async ({ request }) => {
const { access_token } = await login(request);
expect((await request.post(`${BASE}/auth/logout`, { headers: bearer(access_token) })).status()).toBe(204);
expect((await request.get(`${BASE}/auth/me`, { headers: bearer(access_token) })).status()).toBe(401);
});
JWT Security Checklist
- Signature always verified;
alg: noneand algorithm switching (RS256 → HS256) rejected. - Strong secret; tokens from other environments rejected.
- Short
exp; expiry enforced; refresh tokens rotated. - Logout, password change and role change take effect.
- No sensitive data in the payload.
- Authorization decisions use server data or verified claims, never values sent by the client in the body.
- Tokens stored safely on clients (HttpOnly cookies or secure mobile storage).
Interview Questions: JWT Testing
What is a JWT and what are its parts?
A JSON Web Token is a compact, signed token with three Base64URL-encoded parts separated by dots: the header (algorithm and type), the payload (claims such as sub, role, iat and exp) and the signature that proves the first two parts weren’t changed.
Is a JWT encrypted?
Normally no. A standard signed JWT (JWS) is only encoded, so anyone can read the payload. It’s protected against modification, not against reading. Encrypted JWTs (JWE) exist but are less common.
How do you test JWT expiry?
Obtain a token with a short lifetime (test configuration or a parameter), wait past the exp time and confirm the API returns 401. Also check that exp exists, that the lifetime is short and that a reasonable clock-skew tolerance is applied.
How do you test if a JWT signature is verified?
Modify the payload (for example change the role) while keeping the old signature, modify a character of the signature, and try a token with alg set to none. All must return 401.
What is the difference between an access token and a refresh token?
An access token is short-lived and sent with every API call. A refresh token is longer-lived, sent only to the token endpoint, and used to obtain new access tokens without the user logging in again.
What is refresh token rotation?
Each refresh returns a new refresh token and invalidates the old one, so a stolen refresh token can be used only once and reuse can be detected.
How can logout work if JWTs are stateless?
The server keeps a deny-list of revoked token ids (jti) until they expire, revokes the refresh token, or keeps access tokens very short. Testers verify the old token returns 401 after logout.
What is the alg none attack?
An attacker sets the JWT header algorithm to none and removes the signature, hoping the server accepts unsigned tokens. Secure servers reject tokens whose algorithm isn’t the expected one.
What should never be stored in a JWT payload?
Passwords, secrets, full card numbers, government ids or other sensitive personal data, because the payload can be decoded by anyone holding the token.
Where should a web app store JWTs?
Preferably in HttpOnly, Secure, SameSite cookies, which JavaScript can’t read. localStorage is exposed to any XSS vulnerability. Mobile apps should use the platform’s secure storage (Keychain or Keystore).