OAuth 2.0 with Rest Assured

9 min read

OAuth 2.0 with bearer tokens is the dominant auth pattern on the modern public internet. The user (or the test) trades credentials for a short-lived token, then attaches that token to every subsequent request as Authorization: Bearer .... The API Testing Masterclass lesson on OAuth 2.0 flows explained the grant types and what each is for; this lesson is the Rest Assured DSL for the two grants you'll encounter most as a tester — client credentials and resource owner password — plus the practical patterns that turn a one-off token call into a clean, shared auth setup.

The bearer token, end to end

The two-step shape every OAuth test follows: first get the token, then use it.

// Step 1: trade credentials for a token
String token = given()
    .contentType(ContentType.JSON)
    .body(Map.of(
        "grant_type",    "client_credentials",
        "client_id",     "my-test-app",
        "client_secret", System.getenv("OAUTH_CLIENT_SECRET")
    ))
.when()
    .post("/auth/token")
.then()
    .statusCode(200)
    .body("access_token", notNullValue())
    .extract().path("access_token");
 
// Step 2: send the token on the protected request
given()
    .auth().oauth2(token)
.when()
    .get("/users")
.then()
    .statusCode(200);

auth().oauth2(token) is a one-line helper — it sets Authorization: Bearer <token> and nothing else. Rest Assured doesn't validate the token, refresh it, or know anything about expiry. That's deliberate; the next lesson covers the lifecycle.

A manual header is identical

For symmetry — auth().oauth2(...) is exactly equivalent to:

given()
    .header("Authorization", "Bearer " + token)

Some test code prefers the explicit header form because it's obvious what's on the wire. Either works. The decisions you'll make about where the token comes from and how often it refreshes matter far more than which method sets the header.

The client credentials grant

For server-to-server (no human user involved) — most internal/B2B APIs:

String token = given()
    .contentType(ContentType.URLENC)
    .formParam("grant_type",    "client_credentials")
    .formParam("client_id",     "ci-test-app")
    .formParam("client_secret", System.getenv("OAUTH_CLIENT_SECRET"))
    .formParam("scope",         "users:read orders:read")
.when()
    .post("/oauth/token")
.then()
    .statusCode(200)
    .body("token_type", equalTo("Bearer"))
    .body("expires_in", greaterThan(0))
    .extract().path("access_token");

Notes worth flagging:

  • Many auth servers want the body as application/x-www-form-urlencoded, not JSON. Use formParam and contentType(ContentType.URLENC).
  • scope is optional but commonly required. Match what your auth team has provisioned for the test app.
  • expires_in is in seconds. Capture it if you'll need refresh logic (the next lesson goes deep on this).

The resource owner password grant

Less recommended (it requires the test to handle real user credentials) but common in older APIs and internal staging environments:

String token = given()
    .contentType(ContentType.URLENC)
    .formParam("grant_type", "password")
    .formParam("username",   "test.user@mycompany.com")
    .formParam("password",   System.getenv("TEST_USER_PASSWORD"))
    .formParam("client_id",  "test-suite")
.when()
    .post("/oauth/token")
.then()
    .statusCode(200)
    .extract().path("access_token");

Same shape, different grant type, different credential payload. The token comes back with the same fields and is used the same way.

A login-style endpoint that returns a token

Many applications wrap their own login as a normal JSON endpoint that returns a token in the body. It looks like OAuth from the outside but uses a custom shape:

String token = given()
    .contentType(ContentType.JSON)
    .body(Map.of(
        "email",    "admin@test.com",
        "password", System.getenv("ADMIN_PASSWORD")
    ))
.when()
    .post("/auth/login")
.then()
    .statusCode(200)
    .body("token", notNullValue())
    .extract().path("token");

The Rest Assured shape is identical. The body shape and field names differ from the OAuth standard — read your API docs. The downstream usage (.auth().oauth2(token)) is the same regardless.

Sharing one token across an entire test class

The expensive part of OAuth in tests is the token call. Doing it once per class, in @BeforeClass, then attaching the token to every subsequent test via a shared RequestSpecification:

import io.restassured.builder.RequestSpecBuilder;
import io.restassured.specification.RequestSpecification;
import org.testng.annotations.BeforeClass;
 
public class UsersApiTest {
 
    private static RequestSpecification authSpec;
 
    @BeforeClass
    public void authenticate() {
        String token = given()
            .contentType(ContentType.JSON)
            .body(Map.of(
                "email",    System.getenv("TEST_USER_EMAIL"),
                "password", System.getenv("TEST_USER_PASSWORD")
            ))
        .when()
            .post(System.getenv("AUTH_URL") + "/auth/login")
        .then()
            .statusCode(200)
            .extract().path("token");
 
        authSpec = new RequestSpecBuilder()
            .setBaseUri(System.getenv("API_URL"))
            .addHeader("Authorization", "Bearer " + token)
            .setContentType(ContentType.JSON)
            .build();
    }
 
    @Test
    public void listUsers() {
        given().spec(authSpec)
        .when().get("/users")
        .then().statusCode(200);
    }
 
    @Test
    public void getCurrentUser() {
        given().spec(authSpec)
        .when().get("/users/me")
        .then().statusCode(200);
    }
}

One token call, every test inherits it via given().spec(authSpec). The pattern is what most production frameworks settle on, with refinements (token refresh, env-aware base URLs) covered in Chapter 6.

The auth dance — visualised

The work the test framework needs to do is small: one POST to mint, one Bearer header to attach. The complexity is the auth server's — and it's exactly the boundary you don't want to mock, because token validation is a meaningful part of what end-to-end tests prove.

Asserting on the token itself

When the auth endpoint is what you're testing (an auth-team-owned suite), assert on the token's shape:

@Test
public void clientCredentialsReturnsValidJwt() {
    given()
        .contentType(ContentType.URLENC)
        .formParam("grant_type", "client_credentials")
        .formParam("client_id", "my-app")
        .formParam("client_secret", System.getenv("OAUTH_CLIENT_SECRET"))
    .when()
        .post("/oauth/token")
    .then()
        .statusCode(200)
        .body("access_token", matchesPattern("^[A-Za-z0-9-_]+\\.[A-Za-z0-9-_]+\\.[A-Za-z0-9-_]+$"))
        .body("token_type", equalTo("Bearer"))
        .body("expires_in", greaterThan(0))
        .body("scope", containsString("users:read"));
}

The regex matches a standard three-segment JWT (header.payload.signature). For opaque tokens, swap to notNullValue() and not(emptyString()).

Auth tests worth running on every endpoint

Even when each test class shares a happy-path token, the auth-failure cases deserve their own small suite:

@Test public void invalidTokenReturns401() {
    given().auth().oauth2("not-a-real-token")
    .when().get("/users")
    .then().statusCode(401);
}
 
@Test public void missingTokenReturns401() {
    given()
    .when().get("/users")
    .then().statusCode(401);
}
 
@Test public void expiredTokenReturns401() {
    String expiredToken = System.getenv("TEST_EXPIRED_TOKEN"); // pre-baked
    given().auth().oauth2(expiredToken)
    .when().get("/users")
    .then().statusCode(401);
}

These three live once per service, separate from the per-resource test classes. They prove the auth filter is doing its job — without them, a regression that disabled auth on /users would never be noticed.

⚠️ Common mistakes

  • Calling /auth/token in @BeforeMethod. That re-mints a token before every test, which can rate-limit your auth server (some have aggressive limits) and slows the suite to a crawl. Use @BeforeClass (one token per test class) or @BeforeSuite (one token per run) — the next lesson on token management explains when to refresh.
  • Storing the raw token in a logged variable. System.out.println(token) ends up in CI logs, which end up in a corporate observability tool, which means a leaked production token. Log only the shape (token starts with eyJh...) or skip logging tokens entirely.
  • Using @Test(dependsOnMethods) to thread a token from one test to another. The token belongs in a shared spec, not a chained test. dependsOnMethods couples the order of tests; tests should be independently runnable.

🎯 Practice task

Two free OAuth-style sandboxes work for this drill: REQRES (returns a fake token) or — if you have one — your own internal staging API. 25–35 minutes.

  1. Set RestAssured.baseURI = "https://reqres.in". Write loginReturnsToken() — POST to /api/login with email = "eve.holt@reqres.in" and password = "cityslicka". Assert 200 and extract token.
  2. Use the captured token in a follow-up GET (REQRES doesn't enforce auth — you're just rehearsing the shape). Add .auth().oauth2(token) to a GET /api/users/2.
  3. Class-level setup. Move the login into @BeforeClass and store the token on a static String field. Three test methods all call GET endpoints with auth().oauth2(token) — confirm only one login call appears in the suite output.
  4. RequestSpecBuilder. Refactor: build a RequestSpecification in @BeforeClass with the bearer header baked in, and call given().spec(authSpec) in every test. The class shrinks visibly.
  5. Negative auth tests. Write invalidTokenStillReturns200OnReqres() — REQRES doesn't enforce auth, so the test will pass regardless of token validity. Note this in a comment as something a real API would catch with a 401. This contrast is the whole point.
  6. Token shape assertion. Add .body("token", matchesPattern("^[A-Za-z0-9]+$")) to the login test. Run it green.
  7. Read credentials from env vars. Replace the hardcoded credentials with System.getenv("REQRES_EMAIL") and System.getenv("REQRES_PASSWORD"). Set them in your shell or IDE run config before running. Internalise that this is the lowest acceptable bar for credentials in test code.
  8. Stretch: find an OAuth-protected API you have access to (Auth0, Okta, your company's API) and replace the REQRES login with a real token call. Run one happy-path test. Note how identical the Rest Assured chain is — the shift is in env vars and base URLs, not in test shape.

Next lesson: tokens that expire. How to detect expiry, refresh transparently, and write tests for the refresh flow itself — without leaking the complexity into every test method.

// tip to track lessons you complete and pick up where you left off across devices.