API Testing
Intermediate 13 min read API Testing

Authentication Testing

Authentication testing verifies every layer of identity and access: credentials, tokens, expiry, revocation, roles and scopes. Test that the right callers get in, the wrong ones are rejected, and access is limited to what each role is allowed to do.

authenticationauthorizationJWTOAuth 2.0API keysaccess control
Introduction

What is this?

Authentication testing verifies identity and access control across the whole token lifecycle: issuing credentials, presenting tokens, expiry, revocation, roles and scopes. You prove that the right callers get in, the wrong ones are rejected, and each identity can only reach what it is allowed to. Because auth bugs are security-critical, the suite must cover happy paths, negative cases and boundary conditions such as near-expiry tokens.

Motivation

Why do you need it?

  • Broken access control is a top OWASP category; automated tests are the cheapest proof that identity checks actually work.
  • Token lifecycle — issue, refresh, expiry, revoke — is exactly where integration bugs hide between services.
  • Role and scope tests catch authorization leaks where one user reads another user's data.
  • Replay and tamper checks verify that a copied or edited token is rejected by the server.
  • Automated auth suites gate releases so a misconfigured identity provider never ships silently.
Think about it

Mental model

Imagine

A boarding pass

Think of

An access token

Your ID and ticket get you through check-in (authentication), but the boarding pass decides which gates and which cabin you may enter (authorization). Just like a pass, a token carries who you are, when it expires, and which zones you can access — and testing verifies each of those claims.

Mechanics

How it works

  • Obtain a credential set for each identity you need to cover: user, admin, read-only client and an expired token.
  • Request an access token via the documented flow — password grant, client credentials, authorization code or API key.
  • Call protected endpoints with, without and with an invalid token, asserting on the 401/403 outcomes.
  • Verify the token's claims (issuer, audience, expiry, scopes) against the expected values for that identity.
  • Exercise refresh behavior: a refresh token rotates, expires, and cannot be replayed once consumed.

From credentials to an allow/deny decision at the API.

Identity request
Send credentials
Token response
Validate claims
Authorization check
Allow or deny
Reference

Key commands

curl -s -X POST https://auth.example.com/oauth/token -H 'Content-Type: application/x-www-form-urlencoded' -d 'grant_type=client_credentials&client_id=web-app&client_secret=s3cret'

Request an access token with the client_credentials grant.

curl -s https://api.example.com/v1/me -H "Authorization: Bearer $TOKEN"

Call a protected endpoint with a token stored in the TOKEN variable.

jq -R 'split(".")[1] | @base64d | fromjson' <<< "$TOKEN"

Decode and pretty-print the JWT payload to inspect iss, exp and scopes.

{"iss":"https://auth.example.com","exp":1750000000,"scope":["orders:read"]}
curl -s -o /dev/null -w '%{http_code}\n' https://api.example.com/v1/admin

Check that an unauthenticated request to an admin route is rejected.

401
openssl dgst -sha256 -mac HMAC -macopt hexkey:$(cat secret.key) data.txt

Generate an HMAC signature the way many API-key flows do, for signature tests.

Hands-on

Examples

OAuth2 client_credentials with a role-bound call

OAuth2 client_credentials with a role-bound call
import io.restassured.RestAssured;
import io.restassured.http.ContentType;
import org.junit.jupiter.api.Test;
class OAuthApiTest {
@Test
void clientCanReadItsOwnScope() {
String token = RestAssured
.given()
.contentType(ContentType.URLENC)
.formParam("grant_type", "client_credentials")
.formParam("client_id", "web-app")
.formParam("client_secret", "s3cret")
.when()
.post("/oauth/token")
.then()
.statusCode(200)
.extract().path("access_token");
// The client is scoped to read, so a write call must be rejected.
RestAssured
.given()
.auth().oauth2(token)
.accept(ContentType.JSON)
.body("{\"order\":{\"items\":[1,2]}}")
.when()
.post("/v1/orders")
.then()
.statusCode(403);
// Reading its own scope is fine.
RestAssured
.given()
.auth().oauth2(token)
.accept(ContentType.JSON)
.when()
.get("/v1/orders?status=open")
.then()
.statusCode(200);
}
}

The first call exchanges client credentials for an access token using the client_credentials grant. The same token is then used twice: once to assert a 403 when the token's read-only scope attempts a write, and once to assert 200 on a read it is allowed. Positive and negative authorization cases live in the same test so the boundary is explicit.

Real world

In production

  • CI runs an auth matrix covering user, admin and anonymous callers against every environment.
  • Rotating client secrets or tightening token expiry is validated by the auth suite before the change ships.
  • Security reviews cite the auth suite as evidence that OWASP A01-Broken Access Control cases are covered.
  • Refresh-token rotation tests prove a stolen refresh token cannot be replayed across sessions.
Guidelines

Best practices

  • Never put real credentials in tests — inject them via environment variables or a secrets manager.
  • Create dedicated test identities per environment, scoped so they have no access to production data.
  • Assert on both sides of the boundary: 200 for allowed, 401 for missing credentials, 403 for insufficient scope.
  • Test expiry boundaries with short-lived tokens or clock control so 401 behavior is verified without waiting.
  • Verify the token's issuer, audience and scopes instead of trusting the status code alone.
Watch out

Common mistakes

  • Testing only with an admin token, so privilege-escalation leaks go completely unnoticed.
  • Hard-coding tokens that expire, making the suite red for reasons unrelated to the code.
  • Reusing one identity for happy-path and negative tests, masking authorization issues.
  • Checking only the HTTP code while ignoring the error body that reveals token reason codes.
Root cause analysis

Common issues

Error
401 invalid_token on every call right after login
Root cause

The access token was never captured or was not attached to the request headers.

Fix

Store the token from the token response into the variable used by the Authorization header and re-run.

Error
403 Forbidden for a valid token
Root cause

The token's scopes or the caller's role do not include the requested resource.

Fix

Decode the token, compare its scopes with the endpoint requirement, and use an identity with sufficient privileges.

Error
Refresh token fails on its second use
Root cause

The provider rotates refresh tokens and revokes the old one once it is used.

Fix

Re-read the new refresh token from every refresh response instead of caching the original.

When things break

Debugging tips

  • Decode the JWT payload with jq to inspect iss, aud, exp and scopes of the failing token.
  • Read the error body — a 401 with invalid_token differs from one with expired_token.
  • Verify the token is sent in the right format: Authorization: Bearer <token>.
  • Check the identity provider logs to confirm the credential was even accepted at the token endpoint.
Interviews

Interview checkpoint

Practice

Hands-on lab

Test the OAuth2 Token Lifecycle
45 min · intermediate
In progress
Objectives
  • Request a token via the client_credentials grant
  • Call a protected endpoint with a bearer token
  • Verify unauthenticated and invalid-token requests are rejected
  • Verify role and scope enforcement with an under-privileged token

Test the OAuth2 Token Lifecycle

45 min · intermediate

0/5 steps
0%
sandbox · Linux sandbox
$
Try these commands
Test yourself

Quick quiz

0/3
1

Which HTTP status indicates the request is missing or has invalid credentials?

2

A valid access token grants access to every endpoint on the API.

3

A token with the read scope receives a 403 when calling POST /v1/orders. What does this indicate?

Quick reference

Cheat sheet

Bearer token flows
grant_type=client_credentialsMachine-to-machine token grant
grant_type=passwordDirect user credentials (less common)
Authorization: Bearer <token>Present an access token
jq -R 'split(".")[1] | @base64d | fromjson'Decode a JWT payload
Status code semantics
401No credentials or invalid credentials
403Authenticated but not authorized
200/201Authenticated and authorized
Go deeper

Official documentation