Testing the happy path is easy. Anyone can verify that a valid token works. The real value of a QA tester is in the negative tests. What happens when the token is missing? Expired? Malformed? Belongs to a deleted user? This is where bugs hide.
Here is the complete list of authentication tests you should write for any secured API. Miss any of these and you have a gap in your security coverage.
| Scenario | Expected Status | Why It Matters |
|---|---|---|
| Valid token | 200 | Basic sanity — confirms auth works at all |
| No token (header missing) | 401 | Endpoint must not be accessible without auth |
| Empty token ("Bearer ") | 401 | Some servers crash on empty token — regression catch |
| Malformed token ("Bearer xyz") | 401 | Token parsing must fail gracefully |
| Expired token | 401 | Expiry enforcement must work |
| Token with tampered payload | 401 | Signature validation must catch this |
| Token signed with wrong key | 401 | Must reject tokens from other systems |
| Token for deleted/disabled user | 401 | User deactivation must revoke access |
| Token in wrong header format | 401 | e.g., "Token abc" instead of "Bearer abc" |
| SQL injection in token field | 401 | Must not cause server error (500) |
import io.restassured.http.ContentType;
import org.testng.annotations.Test;
import static io.restassured.RestAssured.*;
import static org.hamcrest.Matchers.*;
public class AuthenticationTest extends BaseTest {
private static final String PROTECTED_ENDPOINT = "/users/me";
// ===== POSITIVE TESTS =====
@Test
public void testValidTokenReturns200() {
asUser()
.when()
.get(PROTECTED_ENDPOINT)
.then()
.statusCode(200)
.body("email", notNullValue());
}
@Test
public void testLoginWithValidCredentials() {
given()
.contentType(ContentType.JSON)
.body("""
{
"email": "user@example.com",
"password": "User@1234"
}
""")
.when()
.post("/auth/login")
.then()
.statusCode(200)
.body("token", notNullValue())
.body("token", not(emptyString()))
.body("expiresIn", greaterThan(0));
}
// ===== NEGATIVE TESTS — MISSING TOKEN =====
@Test
public void testNoAuthHeaderReturns401() {
given()
.when()
.get(PROTECTED_ENDPOINT)
.then()
.statusCode(401)
.body("error", notNullValue());
}
@Test
public void testEmptyBearerTokenReturns401() {
given()
.header("Authorization", "Bearer ")
.when()
.get(PROTECTED_ENDPOINT)
.then()
.statusCode(401);
}
@Test
public void testEmptyAuthHeaderReturns401() {
given()
.header("Authorization", "")
.when()
.get(PROTECTED_ENDPOINT)
.then()
.statusCode(401);
}
// ===== NEGATIVE TESTS — INVALID TOKEN =====
@Test
public void testMalformedTokenReturns401() {
given()
.header("Authorization", "Bearer this-is-not-a-jwt")
.when()
.get(PROTECTED_ENDPOINT)
.then()
.statusCode(401);
}
@Test
public void testExpiredTokenReturns401() {
// JWT with exp = 1600000000 (September 2020)
String expiredToken = "eyJhbGciOiJIUzI1NiJ9"
+ ".eyJzdWIiOiIxIiwiZXhwIjoxNjAwMDAwMDAwfQ"
+ ".invalidsignature";
given()
.header("Authorization", "Bearer " + expiredToken)
.when()
.get(PROTECTED_ENDPOINT)
.then()
.statusCode(401);
}
@Test
public void testWrongAuthSchemeReturns401() {
// Sending "Token" instead of "Bearer"
given()
.header("Authorization", "Token " + userToken)
.when()
.get(PROTECTED_ENDPOINT)
.then()
.statusCode(401);
}
// ===== NEGATIVE TESTS — LOGIN FAILURES =====
@Test
public void testLoginWithWrongPassword() {
given()
.contentType(ContentType.JSON)
.body("""
{
"email": "user@example.com",
"password": "WrongPassword!"
}
""")
.when()
.post("/auth/login")
.then()
.statusCode(401)
.body("error", notNullValue());
}
@Test
public void testLoginWithNonExistentUser() {
given()
.contentType(ContentType.JSON)
.body("""
{
"email": "nobody@example.com",
"password": "Password@123"
}
""")
.when()
.post("/auth/login")
.then()
.statusCode(401);
}
@Test
public void testLoginWithEmptyBody() {
given()
.contentType(ContentType.JSON)
.body("{}")
.when()
.post("/auth/login")
.then()
.statusCode(400);
}
@Test
public void testLoginWithSqlInjection() {
given()
.contentType(ContentType.JSON)
.body("""
{
"email": "' OR 1=1 --",
"password": "anything"
}
""")
.when()
.post("/auth/login")
.then()
.statusCode(anyOf(is(400), is(401)))
.body("error", notNullValue());
// Must NOT return 200 or 500
}
}When testing SQL injection in login, you are checking that the server rejects it gracefully (400 or 401). If you get a 500 — that is a critical bug. It means the input reached the database unfiltered. File it immediately.
Create a test data class with constants for invalid tokens, expired tokens, and malformed tokens. Reusing these across tests ensures consistency and makes your suite easier to maintain.
Key Point: A good authentication test suite has MORE negative tests than positive ones. The happy path is one test. The attack surface is ten tests. Focus on what should NOT work.
Q: What negative test cases would you write for an authentication endpoint?
A: I would test: (1) No token — verify 401, (2) Empty token — verify 401, (3) Malformed/garbage token — verify 401, (4) Expired token — verify 401, (5) Token with tampered payload — verify 401, (6) Wrong auth scheme (Token instead of Bearer) — verify 401, (7) Valid token but user deleted — verify 401, (8) Login with wrong password — verify 401, (9) Login with non-existent user — verify 401, (10) SQL injection in credentials — verify 400/401, not 500. The key principle: test everything that should fail and verify it fails correctly.
Key Point: Authentication testing is mostly about negative cases. Test missing, empty, malformed, expired, and tampered tokens. A good suite has 10 negative tests for every positive one.