REST Assured Authentication Testing: OAuth, JWT, and Basic Auth
Authentication is where most API bugs hide. A missing header, an expired token, an incorrect scope — any of these can silently break a protected endpoint in ways that unit tests never catch. REST Assured makes authentication testing straightforward, giving you the tools to verify not just that authenticated requests work, but that unauthenticated and incorrectly authenticated requests fail correctly.
Why Test Authentication Separately
Authentication logic is often cross-cutting — it applies across dozens of endpoints, is implemented in middleware or filters, and involves state (tokens, sessions, scopes) that doesn't fit neatly into unit tests. Integration tests with REST Assured are the right level: they exercise the real HTTP layer and the real security configuration.
The scenarios you need to cover:
- Unauthenticated requests return 401 (not 200, not 403, not 500)
- Requests with invalid credentials return 401 or 403
- Requests with expired tokens return 401
- Authenticated requests with correct permissions succeed
- Authenticated requests with insufficient permissions return 403
- Token refresh works correctly
Basic Authentication
Basic Auth sends credentials as a Base64-encoded Authorization header. REST Assured has built-in support:
import static io.restassured.RestAssured.*;
import static org.hamcrest.Matchers.*;
@Test
void basicAuth_validCredentials_returns200() {
given()
.auth().basic("user@example.com", "correctpassword")
.when()
.get("/api/profile")
.then()
.statusCode(200)
.body("email", equalTo("user@example.com"));
}
@Test
void basicAuth_invalidPassword_returns401() {
given()
.auth().basic("user@example.com", "wrongpassword")
.when()
.get("/api/profile")
.then()
.statusCode(401);
}
@Test
void noAuth_returns401() {
given()
.when()
.get("/api/profile")
.then()
.statusCode(401);
}REST Assured's .auth().basic() handles the Base64 encoding automatically. If you need to test the raw header for some reason:
import java.util.Base64;
@Test
void basicAuth_manualHeader() {
String credentials = Base64.getEncoder()
.encodeToString("user@example.com:correctpassword".getBytes());
given()
.header("Authorization", "Basic " + credentials)
.when()
.get("/api/profile")
.then()
.statusCode(200);
}Bearer Token / JWT Authentication
For APIs that issue JWTs or opaque bearer tokens, the pattern is: obtain a token first, then use it in subsequent requests.
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class JwtAuthTest {
@LocalServerPort int port;
@BeforeEach
void setup() {
RestAssured.port = port;
RestAssured.baseURI = "http://localhost";
}
private String obtainToken(String email, String password) {
return given()
.contentType("application/json")
.body("""
{
"email": "%s",
"password": "%s"
}
""".formatted(email, password))
.when()
.post("/api/auth/login")
.then()
.statusCode(200)
.body("token", notNullValue())
.extract()
.path("token");
}
@Test
void validToken_accessesProtectedEndpoint() {
String token = obtainToken("user@example.com", "password123");
given()
.header("Authorization", "Bearer " + token)
.when()
.get("/api/me")
.then()
.statusCode(200)
.body("email", equalTo("user@example.com"));
}
@Test
void expiredToken_returns401() {
// Use a known expired token (hardcoded or generated with past expiry)
String expiredToken = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ1c2VyQGV4YW1wbGUuY29tIiwiZXhwIjoxNjAwMDAwMDAwfQ.invalid";
given()
.header("Authorization", "Bearer " + expiredToken)
.when()
.get("/api/me")
.then()
.statusCode(401)
.body("error", containsStringIgnoringCase("expired"));
}
@Test
void tamperedToken_returns401() {
String validToken = obtainToken("user@example.com", "password123");
String tamperedToken = validToken.substring(0, validToken.length() - 5) + "XXXXX";
given()
.header("Authorization", "Bearer " + tamperedToken)
.when()
.get("/api/me")
.then()
.statusCode(401);
}
@Test
void malformedAuthHeader_returns401() {
given()
.header("Authorization", "NotBearer sometokenvalue")
.when()
.get("/api/me")
.then()
.statusCode(401);
}
}Sharing Auth State Across Tests
Re-authenticating in every test is slow and creates unnecessary API calls. Cache the token at the class level:
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class AuthenticatedApiTest {
@LocalServerPort int port;
static String adminToken;
static String userToken;
@BeforeAll
static void obtainTokens(@LocalServerPort int port) {
RestAssured.port = port;
RestAssured.baseURI = "http://localhost";
adminToken = login("admin@example.com", "adminpass");
userToken = login("user@example.com", "userpass");
}
static String login(String email, String password) {
return given()
.contentType("application/json")
.body("{\"email\":\"" + email + "\",\"password\":\"" + password + "\"}")
.when()
.post("/api/auth/login")
.then()
.statusCode(200)
.extract()
.path("token");
}
// Convenience method
io.restassured.specification.RequestSpecification asAdmin() {
return given().header("Authorization", "Bearer " + adminToken);
}
io.restassured.specification.RequestSpecification asUser() {
return given().header("Authorization", "Bearer " + userToken);
}
@Test
void adminCanAccessAdminEndpoint() {
asAdmin()
.when()
.get("/api/admin/users")
.then()
.statusCode(200);
}
@Test
void regularUserCannotAccessAdminEndpoint() {
asUser()
.when()
.get("/api/admin/users")
.then()
.statusCode(403);
}
}This pattern mirrors what HelpMeTest does at the UI layer — establish an authenticated session once, then reuse it across all tests. Repeating login in every test is a common source of flakiness and slowness.
OAuth 2.0 Client Credentials Flow
For machine-to-machine APIs using OAuth 2.0 client credentials:
@Test
void oauth2ClientCredentials_returnsValidToken() {
// Step 1: Get access token from authorization server
String accessToken =
given()
.contentType("application/x-www-form-urlencoded")
.formParam("grant_type", "client_credentials")
.formParam("client_id", "my-client-id")
.formParam("client_secret", "my-client-secret")
.formParam("scope", "read:users")
.when()
.post("https://auth.example.com/oauth/token")
.then()
.statusCode(200)
.body("token_type", equalToIgnoringCase("bearer"))
.body("expires_in", greaterThan(0))
.extract()
.path("access_token");
// Step 2: Use the token
given()
.auth().oauth2(accessToken)
.when()
.get("https://api.example.com/users")
.then()
.statusCode(200);
}REST Assured's .auth().oauth2(token) is a convenience method that sets the Authorization: Bearer <token> header.
OAuth 2.0 Authorization Code Flow Testing
Testing the authorization code flow is harder because it involves browser redirects. For integration tests, you typically bypass the browser step and test the token exchange directly:
@Test
void authorizationCodeExchange_returnsTokens() {
// Simulate receiving an auth code (in real OAuth, this comes from the browser)
String authCode = obtainAuthCodeFromTestHelper();
given()
.contentType("application/x-www-form-urlencoded")
.formParam("grant_type", "authorization_code")
.formParam("code", authCode)
.formParam("redirect_uri", "https://myapp.com/callback")
.formParam("client_id", "my-client-id")
.formParam("client_secret", "my-client-secret")
.when()
.post("/oauth/token")
.then()
.statusCode(200)
.body("access_token", notNullValue())
.body("refresh_token", notNullValue())
.body("expires_in", greaterThan(0));
}Token Refresh Testing
@Test
void refreshToken_returnsNewAccessToken() {
// First login to get tokens
var loginResponse = given()
.contentType("application/json")
.body("{\"email\":\"user@example.com\",\"password\":\"password123\"}")
.when()
.post("/api/auth/login")
.then()
.statusCode(200)
.extract()
.response();
String refreshToken = loginResponse.path("refreshToken");
// Use refresh token to get new access token
given()
.contentType("application/json")
.body("{\"refreshToken\":\"" + refreshToken + "\"}")
.when()
.post("/api/auth/refresh")
.then()
.statusCode(200)
.body("accessToken", notNullValue())
.body("accessToken", not(equalTo(loginResponse.path("accessToken"))));
}
@Test
void usedRefreshToken_cannotBeReusedForReplay() {
String refreshToken = getRefreshToken("user@example.com", "password123");
// First use — should work
String newToken = given()
.contentType("application/json")
.body("{\"refreshToken\":\"" + refreshToken + "\"}")
.when()
.post("/api/auth/refresh")
.then()
.statusCode(200)
.extract()
.path("accessToken");
// Second use of same refresh token — should fail (rotation)
given()
.contentType("application/json")
.body("{\"refreshToken\":\"" + refreshToken + "\"}")
.when()
.post("/api/auth/refresh")
.then()
.statusCode(401);
}The second test covers token rotation — a security control that invalidates a refresh token after first use to prevent replay attacks. This is exactly the kind of security behavior that's easy to forget and hard to catch without explicit tests.
API Key Authentication
@Test
void apiKey_inHeader_returns200() {
given()
.header("X-API-Key", "valid-api-key-here")
.when()
.get("/api/data")
.then()
.statusCode(200);
}
@Test
void apiKey_inQueryParam_returns200() {
given()
.queryParam("api_key", "valid-api-key-here")
.when()
.get("/api/data")
.then()
.statusCode(200);
}
@Test
void apiKey_revoked_returns401() {
given()
.header("X-API-Key", "revoked-key")
.when()
.get("/api/data")
.then()
.statusCode(401)
.body("error", containsString("revoked"));
}
@Test
void apiKey_wrongPermissions_returns403() {
given()
.header("X-API-Key", "read-only-key")
.when()
.post("/api/data")
.then()
.statusCode(403);
}Using RequestSpecification for DRY Auth Tests
When many tests need the same auth setup, define it once as a RequestSpecification:
public class ApiTestBase {
protected static RequestSpecification adminSpec;
protected static RequestSpecification readOnlySpec;
@BeforeAll
static void buildSpecs() {
String adminToken = login("admin@example.com", "adminpass");
String readOnlyToken = login("readonly@example.com", "readonlypass");
adminSpec = new RequestSpecBuilder()
.addHeader("Authorization", "Bearer " + adminToken)
.setContentType(ContentType.JSON)
.setBaseUri("http://localhost")
.build();
readOnlySpec = new RequestSpecBuilder()
.addHeader("Authorization", "Bearer " + readOnlyToken)
.setContentType(ContentType.JSON)
.setBaseUri("http://localhost")
.build();
}
}
// In tests
class ProductAdminTest extends ApiTestBase {
@Test
void adminCanDeleteProduct() {
given(adminSpec)
.when()
.delete("/api/products/1")
.then()
.statusCode(204);
}
@Test
void readOnlyCannotDeleteProduct() {
given(readOnlySpec)
.when()
.delete("/api/products/1")
.then()
.statusCode(403);
}
}What to Cover in Your Auth Test Suite
A minimum viable authentication test suite for any API:
- Happy path — valid credentials succeed, token returned, subsequent request with token succeeds
- Wrong password — 401, not 200 or 500
- Non-existent user — 401 (same response as wrong password — don't enumerate users)
- Missing auth header — 401
- Malformed auth header — 401
- Expired token — 401 with meaningful error message
- Tampered/invalid token — 401
- Insufficient permissions — 403, not 401
- Token refresh — new token issued, old token invalidated
- Logout/revocation — revoked token rejected on next request
Most APIs have gaps in cases 3–10. REST Assured makes covering them straightforward.
Conclusion
Authentication testing with REST Assured comes down to three patterns: obtain credentials, make a request with those credentials, and assert the correct response. The power is in systematically covering both the success cases and the failure cases — especially the edge cases around expired tokens, revocation, and permission levels that are easy to overlook. Test your auth layer the same way you test your business logic: exhaustively, with clear assertions, and in CI on every commit.