JWT · Authentication · API testing · Tutorial
How to test JWT authentication in an API
A practical guide to testing JWT-protected APIs: capture tokens in scripts, assert on claims, test 401 and 403 cases, mint test tokens with the JWT auth type and handle refresh.
To test JWT authentication in an API, call the login endpoint, save the returned token in a variable with a test script, and send it on protected requests as Authorization: Bearer <token>. Then test the failure cases on purpose: no token, an expired token, a token with a broken signature and a token without the right permissions, and check that each gets the status code your API documents (usually 401 or 403). In Swastik Codes the whole suite is a collection: one login request, Bearer auth inherited from the collection, and swc.test assertions on every response.
This guide walks through that collection step by step, with scripts you can paste into the app. It covers custom login endpoints that return a JWT. If your API uses an OAuth 2.0 provider such as Auth0, Okta or Keycloak, the token step is different; see OAuth 2.0 in an API client and come back here for the testing part.
What a JWT is
A JSON Web Token (JWT, RFC 7519) is a compact, URL-safe string that carries a set of claims. The usual form is a signed token (a JWS) made of three Base64url-encoded parts separated by dots:
header.payload.signature
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ
.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c- The header names the signing algorithm, for example
{"alg":"HS256","typ":"JWT"}. - The payload holds the claims, for example
{"sub":"1234567890","name":"John Doe","iat":1516239022}. - The signature is computed over
header.payloadwith a secret (HMAC algorithms such as HS256) or a private key (RS256, ES256, EdDSA and others).
A signed JWT is not encrypted. Anyone holding it can decode the payload, so tests may read the claims freely, and APIs should never put secrets in them. The server trusts a token only after it verifies the signature and checks the time-based claims.
The registered claims you will meet most often:
| Claim | Meaning | What to test |
|---|---|---|
sub | subject, usually the user id | matches the user who logged in |
exp | expiry, in seconds since 1970-01-01 UTC | in the future, and a sensible distance away |
iat | issued-at time | close to now |
nbf | not valid before | not in the future, unless intended |
iss | issuer | your auth service |
aud | audience | the API that should accept the token |
jti | unique token id | present if your API uses it for revocation |
Roles, scopes and tenant ids are usually custom claims; test them the same way.
Step 1: log in and save the token
Create a collection for the API and a collection variable baseUrl pointing at your server, or define it per environment as described in environments and variables. Add a request:
POST {{baseUrl}}/auth/login
Content-Type: application/json
{ "email": "{{email}}", "password": "{{password}}" }Set this request's Authorization tab to No Auth, because you do not have a token yet. Keep email and password in an environment's current values or in the encrypted vault rather than in the request.
In the request's Scripts tab, add a post-response script that checks the reply and stores the token:
swc.test('login returns 200', function () {
swc.response.to.have.status(200);
});
const body = swc.response.json();
swc.test('response contains a JWT', function () {
swc.expect(body.access_token, 'access_token').to.be.a('string');
swc.expect(body.access_token.split('.')).to.have.lengthOf(3);
});
swc.environment.set('accessToken', body.access_token);
if (body.refresh_token) {
swc.environment.set('refreshToken', body.refresh_token);
}Field names differ between APIs (token, accessToken, access_token), so match yours. swc.environment.set needs an active environment; if you are not using one, write to swc.collectionVariables instead. If you already have Postman scripts, the same code works with pm. in place of swc..
Step 2: send the token as a Bearer header
Open the collection, select its Authorization tab, choose Bearer Token and enter {{accessToken}} as the token. Every request left on Inherit auth from parent now sends:
Authorization: Bearer eyJhbGciOi...The login request keeps its own No Auth setting. Add a protected request such as GET {{baseUrl}}/me with a test:
swc.test('authenticated request succeeds', function () {
swc.response.to.have.status(200);
});
swc.test('returns the logged-in user', function () {
swc.expect(swc.response.json().email).to.eql(swc.environment.get('email'));
});Send the login request first, then /me. In the collection runner, keep the login request at the top so every later request has a fresh token. Runs persist variables changed by scripts by default, so the token saved by the first request is used by the rest.
Step 3: decode the token and check its claims
The sandbox provides atob and btoa, which is all you need to read a JWT. Base64url uses - and _ instead of + and / and drops the padding, so convert before decoding:
function decodePart(part) {
let b64 = part.replace(/-/g, '+').replace(/_/g, '/');
while (b64.length % 4) b64 += '=';
return JSON.parse(decodeURIComponent(escape(atob(b64))));
}
const token = swc.response.json().access_token;
const [headerPart, payloadPart] = token.split('.');
const header = decodePart(headerPart);
const claims = decodePart(payloadPart);
const now = Math.floor(Date.now() / 1000);
swc.test('token uses the expected algorithm', function () {
swc.expect(header.alg).to.be.oneOf(['RS256', 'ES256']);
});
swc.test('token is issued for this API', function () {
swc.expect(claims.iss).to.eql('https://auth.example.com');
swc.expect(claims.aud).to.include('orders-api');
});
swc.test('token expires in about 15 minutes', function () {
swc.expect(claims.exp).to.be.above(now);
swc.expect(claims.exp - claims.iat).to.be.within(840, 960);
});Add this to the login request's post-response script, after the token is saved. Put the decodePart helper at the top of any script that needs it. aud may be a string or an array, hence to.include.
A note on the alg check: a server that accepts "alg": "none" or lets the token choose its own algorithm is a well-known vulnerability, so it is worth asserting that the token uses the algorithm you expect and, below, that the API rejects other ones.
For HS256 tokens you can also verify the signature in a test with the bundled crypto-js, if your test environment knows the signing secret:
const CryptoJS = require('crypto-js');
const [h, p, s] = token.split('.');
const expected = CryptoJS.HmacSHA256(h + '.' + p, swc.environment.get('jwtSecret'))
.toString(CryptoJS.enc.Base64)
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
swc.test('signature verifies with the test secret', function () {
swc.expect(s).to.eql(expected);
});Step 4: test the failure cases
Most JWT bugs are in the paths that should fail. HTTP has two status codes for them, and APIs mix them up often:
| Status | Meaning (RFC 9110) | Typical JWT cases |
|---|---|---|
| 401 Unauthorized | the request has no valid credentials; the response must include a WWW-Authenticate header | no token, malformed token, bad signature, expired token, wrong issuer or audience |
| 403 Forbidden | the server understood the credentials and refuses anyway | valid token, but the user lacks the role or scope |
For Bearer tokens, RFC 6750 adds detail: an invalid or expired token gets 401 with WWW-Authenticate: Bearer error="invalid_token", and a token without the required scope gets 403 with error="insufficient_scope". Your API may document something else; test what it documents. See HTTP status codes for the rest of the list.
Create a folder Auth failures and add one request per case. Set each request's auth explicitly so it does not inherit the valid token.
Missing token
Same URL as /me, auth set to No Auth:
swc.test('missing token is rejected with 401', function () {
swc.response.to.be.unauthorized;
});
swc.test('401 includes a Bearer challenge', function () {
swc.response.to.have.header('WWW-Authenticate');
swc.expect(swc.response.headers.get('WWW-Authenticate')).to.match(/^Bearer/i);
});Tampered payload
Take the valid token, change a claim and keep the old signature. A correct server rejects it. Set the request to No Auth and add this pre-request script:
function decodePart(part) {
let b64 = part.replace(/-/g, '+').replace(/_/g, '/');
while (b64.length % 4) b64 += '=';
return JSON.parse(decodeURIComponent(escape(atob(b64))));
}
function encodePart(obj) {
return btoa(unescape(encodeURIComponent(JSON.stringify(obj))))
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}
const [h, p, s] = swc.environment.get('accessToken').split('.');
const claims = decodePart(p);
claims.role = 'admin';
swc.request.headers.upsert({
key: 'Authorization',
value: 'Bearer ' + h + '.' + encodePart(claims) + '.' + s
});The test is swc.response.to.be.unauthorized. The same pre-request pattern covers the alg: none case: build the header {"alg":"none","typ":"JWT"}, encode it with encodePart, and send header.payload. with an empty signature. Expect 401.
Expired or wrongly signed token
You can create test tokens without touching your auth server: the JWT Bearer authorization type signs a token from the payload you type and sends it with the request. It supports HS256/384/512, RS256/384/512, PS256/384/512, ES256/384/512 and EdDSA, takes an HMAC secret (optionally Base64-encoded) or a PEM private key, accepts extra header fields such as kid, and can send the token in the Authorization header (prefix Bearer by default) or as a query parameter.
The payload is signed exactly as typed; no claims are added for you. To make an expired token, use an exp in the past:
{
"sub": "42",
"iss": "https://auth.example.com",
"aud": "orders-api",
"iat": 1700000000,
"exp": 1700000600
}With the real test signing secret this token has a valid signature but expired long ago, so the API should answer 401. Keep the payload and change the secret to any other value, and you have a token with an invalid signature, which should also get 401. Fields accept {{variables}}, including in the payload, so keep the secret in an environment or the vault, not in the request.
Use only a test or staging signing key for this, never a production one.
Valid token, insufficient permissions
Log in as a user without the admin role (a second login request saving userToken), then call an admin endpoint with Bearer {{userToken}}:
swc.test('non-admin gets 403, not 401', function () {
swc.response.to.be.forbidden;
});If this returns 401, the API is treating "not allowed" as "not authenticated", and clients will wrongly try to log in again.
Step 5: test token refresh
Short-lived access tokens are usually paired with a refresh token. Test the refresh endpoint as its own request:
// POST {{baseUrl}}/auth/refresh with body { "refresh_token": "{{refreshToken}}" }
const body = swc.response.json();
swc.test('refresh returns a new access token', function () {
swc.response.to.have.status(200);
swc.expect(body.access_token).to.not.eql(swc.environment.get('accessToken'));
});
swc.environment.set('previousRefreshToken', swc.environment.get('refreshToken'));
swc.environment.set('accessToken', body.access_token);
swc.environment.set('refreshToken', body.refresh_token);If your API rotates refresh tokens, add a request that sends {{previousRefreshToken}} again and expects it to be rejected. A reused refresh token being accepted is a real security gap, and it is easy to miss without a test. Also test logout: after it, the refresh token should no longer work.
For long collection runs, a collection-level pre-request script can refresh the token when it is about to expire, with swc.sendRequest:
// paste the decodePart() helper from Step 3 here
const token = swc.environment.get('accessToken');
const exp = token ? decodePart(token.split('.')[1]).exp : 0;
if (exp - 30 < Date.now() / 1000 && swc.environment.get('refreshToken')) {
swc.sendRequest({
url: swc.environment.get('baseUrl') + '/auth/refresh',
method: 'POST',
header: { 'Content-Type': 'application/json' },
body: { mode: 'raw', raw: JSON.stringify({ refresh_token: swc.environment.get('refreshToken') }) }
}, function (err, res) {
if (err) { console.log(err); return; }
swc.environment.set('accessToken', res.json().access_token);
swc.environment.set('refreshToken', res.json().refresh_token);
});
}Requests made from scripts show up in the console. More script patterns are in writing API tests with swc.* scripts, and passing values between requests is covered in chaining API requests.
Running the suite
Order the collection as: login, protected requests, the Auth failures folder, refresh. Run it in the collection runner with the environment selected, and export the results as JUnit XML or HTML if you need them in CI or a ticket. To catch a broken auth deployment early, attach the collection to a monitor and get an email or webhook alert when a test fails.
Requests from the web app are sent through the swastik.codes server, which needs a free account and refuses private and loopback addresses. An auth service on localhost needs the desktop app for Windows or Linux; see testing a localhost API.
Frequently asked questions
Should an expired JWT return 401 or 403?
- The token is no longer valid credentials, so the request is unauthenticated. RFC 6750 specifies 401 with
WWW-Authenticate: Bearer error="invalid_token"for expired tokens. 403 is for a valid token whose user or scopes are not allowed to do the action.
How do I decode a JWT in a test script?
Split the token on ., convert the second part from Base64url to Base64 (replace - with + and _ with /, then pad with =), decode it with atob and parse the JSON. The decodePart helper above does this. Decoding does not verify the signature; only the server, or a test that knows the key, can do that.
Can Swastik Codes generate a JWT for me?
Yes. The JWT Bearer authorization type signs a token from your payload on every send, with HMAC, RSA, RSA-PSS, ECDSA or EdDSA algorithms, and adds it as a header or query parameter. It is useful for services that accept self-signed tokens and for creating expired or wrongly signed tokens in negative tests.
How do I reuse one token across all requests?
Save it to a variable in the login request's test script, set the collection's auth to Bearer with {{accessToken}}, and leave the other requests on Inherit auth from parent. Set the login request itself to No Auth.
Is it safe to paste a JWT into an API client?
Treat access tokens like passwords: they grant access until they expire. Keep them in environment current values or the vault, which are not shared with your team, use test accounts, and avoid long-lived tokens.