REST Assured Authentication Testing: OAuth, JWT, and Basic Auth

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:

  1. Happy path — valid credentials succeed, token returned, subsequent request with token succeeds
  2. Wrong password — 401, not 200 or 500
  3. Non-existent user — 401 (same response as wrong password — don't enumerate users)
  4. Missing auth header — 401
  5. Malformed auth header — 401
  6. Expired token — 401 with meaningful error message
  7. Tampered/invalid token — 401
  8. Insufficient permissions — 403, not 401
  9. Token refresh — new token issued, old token invalidated
  10. 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.

Read more

Start now free