JWT Token Testing: Structure, Expiry, Tampering, Refresh and Logout

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.

Quick answerA JWT is a signed token made of three Base64URL parts separated by dots: header (algorithm), payload (claims such as 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.
Advertisement

What Is a JWT? (The Cinema Wristband Example)

Real-life example: a festival wristband At a music festival, you show your ticket and ID once at the gate and get a wristband printed “VIP, valid 12 Nov”. Inside, every stage checks only the wristband, not your ID. The wristband is stamped with a hologram nobody can fake, so if you write “VIP” with a pen on a normal band, guards spot it. A JWT is that wristband: issued once at login, readable by anyone, trusted only because of its signature, and useless after it expires.

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.

Encoded, not encrypted Anyone can read a JWT payload (paste it into a decoder like jwt.io). Never put passwords, card numbers, Aadhaar/PAN or other secrets in it. Testers should decode every token they see and check this.

Standard claims to check

ClaimMeaningWhat to test
expExpiry timeExists; short (minutes, not months); enforced
iat / nbfIssued at / not beforeToken used before nbf is rejected
issIssuerTokens from another issuer are rejected
audAudienceA token for API A doesn’t work on API B
subUserServer uses it, not a user id sent in the body
jtiToken idSupports revocation and replay detection
Advertisement

The Token Lifecycle on the Practice API

StepRequestResult
1. Log inPOST /auth/login {"username":"admin","password":"admin@123"}200: access_token (900 s), refresh_token, user
2. UseGET /auth/me + Authorization: Bearer <access>200: user, role, can_write
3. RefreshPOST /auth/refresh {"refresh_token":"..."}200: new access and new refresh token
4. Reuse old refreshSame refresh token again401 invalid_refresh_token (rotation)
5. Log outPOST /auth/logout + Bearer204
6. Use after logoutGET /auth/me with the old token401 token_revoked

Start by logging in here, then copy the access_token into Postman or the cURL commands below:

POSThttps://sqatools.in/wp-json/sqa-api/v1/auth/login
curl -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)

#ScenarioHowExpected
1Valid tokenBearer + fresh token200
2No tokenNo Authorization header401
3Garbage tokenBearer abc.def401
4Wrong schemeToken <jwt>401
5Tampered payloadChange "role":"viewer" to "admin", keep signature401 (signature invalid)
6Tampered signatureChange one character of part 3401
7alg: none attackHeader {"alg":"none"}, empty signature401
8Expired tokenLogin with expires_in: 5, wait 7 s401
9Lifetime boundaryexpires_in: 4 / 5 / 3600 / 3601422 / 200 / 200 / 422
10Refresh worksPOST /auth/refresh200, new pair
11Refresh reuseSame refresh token twiceSecond: 401
12Refresh missingEmpty body422
13Logout revokesUse token after /auth/logout401
14Token from another environmentStaging token on production401 (different secret/issuer)
15No secrets in payloadDecode the tokenNo password, PII limited
16Token not in URL / logsInspect requests and logsHeader only
Advertisement

Testing Tampering Step by Step

Why it matters: the “make me admin” attack A viewer decodes their token, sees "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.
  1. Log in as viewer / viewer@123 and copy the token.
  2. Split it at the dots. Base64URL-decode the middle part.
  3. Change "role":"viewer" to "role":"admin", encode it again (Base64URL, no padding).
  4. Rebuild header.newPayload.originalSignature and call GET /auth/me.
  5. 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_in field.
  • 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.

Advertisement

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.

Real-life example Banking apps often keep you signed in for a few days but ask for your PIN or fingerprint before payments. Behind the scenes, short access tokens are refreshed silently, and sensitive actions demand fresh authentication.

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.

Advertisement

JWT Testing in Postman

  1. 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);
  2. On GET /auth/me, set Authorization → Bearer Token to {{access_token}}.
  3. Duplicate the request, change the token to {{access_token}}x and assert 401.

Full Postman scripting is in Postman Tests and Newman.

Advertisement

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: none and 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).
Advertisement

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).

Advertisement
View the full API testing syllabus