OAuth 2.0 Concepts for Testers

OAuth 2.0 (RFC 6749) lets an application get limited access to an API without seeing the user's password. The user signs in at a server they trust and approves what the application may do; the application then receives an access token that carries only that permission. The RFC's own example: a printing service reads your photos at a photo-sharing site without ever knowing your password there.

Why it matters

OAuth defects are security defects. A redirect URI that is not matched exactly can deliver codes to an attacker, a missing state check accepts a forged sign-in, an unenforced scope turns read access into write access, and a refresh token that never expires keeps a lost phone signed in. RFC 9700, the current OAuth security best practice, describes these attacks and their countermeasures.

For a tester, one sign-in crosses the browser, the client, the authorization server and the API. Knowing the roles and messages turns "login does not work" into the exact step that failed and its error.

The four roles and what they exchange

RFC 6749 defines four roles: the resource owner (usually the user), the client (the application that wants access), the authorization server (signs the user in, asks for consent, issues tokens) and the resource server (the API). A confidential client, such as a web app with a back end, can keep a secret; a public client, such as a single-page or mobile app, cannot.

  • Authorization code: short-lived and single-use, returned through the browser; RFC 6749 recommends at most 10 minutes.
  • Access token: sent to the API, usually as a Bearer token as in the API authentication lesson. OAuth does not fix its format: a JWT or an opaque string.
  • Refresh token: optional and longer-lived; sent only to the authorization server for new access tokens, never to the API.
  • Scope: permissions such as appointments.read. The server may grant fewer than requested and must then return the granted scope.

Try it: OAuth Token Decoder Try it: Mock Authentication Response

The authorization code flow with PKCE

A grant is how a client obtains tokens. With a user involved, use the authorization code grant with PKCE (Proof Key for Code Exchange, RFC 7636). The user signs in at the authorization server, so the password never reaches the client. The server then redirects the browser back to the client with a code; this browser leg is the front channel. The client redeems the code in a direct request to the token endpoint, the back channel.

PKCE binds the code to the client that asked for it. The client makes up a random code_verifier and sends only its SHA-256 hash, encoded as Base64url, as the code_challenge (method S256). A hash is a one-way fingerprint: anyone can compute it from the verifier, but no one can work the verifier out from it. When the client redeems the code it presents the verifier itself, and the server checks that it hashes to the challenge, so a stolen code is useless without it. RFC 9700 requires PKCE for public clients and recommends it for confidential ones.

Authorization code flow with PKCE: the client sends a hashed challenge, gets a one-time code back through the browser, redeems it with the secret verifier, then calls the API with the access token.
  • User and browser resource owner
  • Client app public client
  • Authorization server auth.example.com
  • Resource server the API
  1. 1 Client app internally: On sign-in, creates state and the PKCE pair code_challenge is the Base64url SHA-256 of code_verifier; the verifier stays in the client
  2. 2 From Client app To Authorization server: Authorization request With client_id, redirect_uri, scope, state and the challenge GET /authorize?response_type=code…
  3. 3 From User and browser To Authorization server: Signs in and approves the scopes The password goes only to the authorization server
  4. 4 From Authorization server To Client app: Redirect back with a one-time code reply To the registered redirect_uri, with the same state 302 /cb?code=…&state=…
  5. 5 Client app internally: Checks state against the session
  6. 6 From Client app To Authorization server: Redeems the code with the verifier Sends the code, the same redirect_uri and code_verifier POST /token
  7. 7 Authorization server internally: Checks the code and the PKCE pair Unused, unexpired, same redirect_uri, verifier hashes to the challenge; otherwise invalid_grant
  8. 8 From Authorization server To Client app: Access token, expiry and an optional refresh token reply Plus an ID token when openid was requested
  9. 9 From Client app To Resource server: Calls the API with the access token Authorization: Bearer …
  10. 10 Resource server internally: Validates the token Expiry, audience and the scope this endpoint needs
  11. 11 From Resource server To Client app: Data, or a refusal reply 200; or 401 with invalid_token, 403 with insufficient_scope

Outcome: A stolen code cannot be redeemed without the verifier, and the password never reaches the client or the API.

Steps 2 to 4 travel through the browser (front channel); steps 6 to 8 are a direct request from the client (back channel). Error branches are left out.

  • state: a random value tied to the browser session and checked on return, so a forged callback is rejected.
  • redirect_uri: matched exactly against a registered value. On a mismatch the server shows an error and does not redirect.
# Front channel: the browser carries the authorization request (wrapped for reading)
GET /authorize?response_type=code&client_id=booking-app
    &redirect_uri=https%3A%2F%2Fapp.example.com%2Fcb
    &scope=openid%20appointments.read&state=af0ifjsldkj
    &code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM
    &code_challenge_method=S256 HTTP/1.1
Host: auth.example.com

# Front channel: after sign-in and consent, the redirect back with the code
HTTP/1.1 302 Found
Location: https://app.example.com/cb?code=SplxlOBeZQQYbYS6WxSbIA&state=af0ifjsldkj

# Back channel: the client redeems the code with the verifier (body wrapped for reading)
POST /token HTTP/1.1
Host: auth.example.com
Content-Type: application/x-www-form-urlencoded

grant_type=authorization_code&code=SplxlOBeZQQYbYS6WxSbIA&client_id=booking-app
&redirect_uri=https%3A%2F%2Fapp.example.com%2Fcb
&code_verifier=dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk

Try it: SHA-256 Generator

Client credentials, refresh and expiry

With no user involved, such as a nightly reporting job, a confidential client uses the client credentials grant: it authenticates with its own credentials and receives an access token for its own permissions, normally without a refresh token.

Access tokens are short-lived, so a leaked one is useful only briefly. On expiry the client sends grant_type=refresh_token and cannot gain scope the user never approved. For public clients RFC 9700 requires refresh tokens to be sender-constrained (cryptographically bound to that client instance, so a copied token is useless on its own) or rotated: with rotation each refresh returns a new refresh token and invalidates the old one.

# Basic credentials = Base64 of client_id:client_secret (example values from RFC 6749)
POST /token HTTP/1.1
Host: auth.example.com
Authorization: Basic czZCaGRSa3F0MzpnWDFmQmF0M2JW
Content-Type: application/x-www-form-urlencoded

grant_type=client_credentials&scope=reports.read

HTTP/1.1 200 OK
Content-Type: application/json
Cache-Control: no-store

{"access_token":"2YotnFZFEjr1zCsicMWpAA","token_type":"Bearer",
 "expires_in":900,"scope":"reports.read"}

OpenID Connect in brief

OAuth 2.0 tells the client what it may access, not who the user is. OpenID Connect (OIDC) adds identity: with the scope openid, the token response also carries an ID token, a JWT for the client that states who signed in, with claims such as iss, sub (the user's id at that issuer), aud (the client's own client_id), exp and, if the client sent one, nonce.

The client validates the ID token as the JWT guide describes and keeps it; only the access token goes to the API.

Worked example: testing a booking app's sign-in

A clinic booking app, a public client, uses the authorization code flow with PKCE and OIDC; its API requires appointments.read to list and appointments.write to book. You test the integration in the test environment with test users and the test client registration, not the identity provider's own product. The token endpoint returns its error value in a JSON body; the API returns it in the WWW-Authenticate challenge (RFC 6750). Most conditions are negative:

  • Sign in, approve and list appointments → 200; the granted scope equals what the user approved.
  • Alter state in the callback → the app rejects it and creates no session.
  • Point redirect_uri at another host → an error page and no redirect.
  • Redeem a code twice, or send a wrong code_verifier → 400 with invalid_grant.
  • Deny consent → the app handles error=access_denied with a clear message.
  • Book with a read-only token → 403 with insufficient_scope.
  • Call the API with an expired access token or with the ID token → 401 with invalid_token.
  • With rotation on, reuse an old refresh token → 400 with invalid_grant, and RFC 9700 expects the newest refresh token to be revoked as well.

Try it: API Authentication Tester Try it: JWT Expiration Checker

Common mistakes

  • Treating an access token as proof of who the user is. Identity comes from a validated OIDC ID token.
  • Testing only the happy path in the browser, never the token endpoint with a reused code, a wrong verifier or a changed redirect_uri.
  • Checking that the API accepts valid tokens but not that it rejects expired ones, ones issued for another API, or ones without the required scope.
  • Pasting real tokens or client secrets into tickets, chats or screenshots instead of redacting them.
  • Security-testing a third-party identity provider without written permission instead of your own integration.

The QA perspective

Start at design review. For each client ask which grant it uses, whether it is public or confidential, whether PKCE is enforced, which redirect URIs are registered, which endpoint needs which scope, how long tokens live and whether refresh tokens rotate. The answers are your test basis; a missing answer is already a finding.

Many expected results are configuration, so work with the developers and the authorization server's administrator. Record the client_id, grant, requested and granted scopes, endpoint, status, OAuth error value and request id, and redact every token and secret.

Practice exercise

Check a PKCE pair and a token response

Your task

In the test environment the booking app requested scope=openid appointments.read with the PKCE pair below and received the token response under it. Verify the pair, review the response and write four negative test conditions for the token endpoint.

Material
code_verifier:  dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk
code_challenge: E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM   (method S256)

HTTP/1.1 200 OK
Content-Type: application/json
Cache-Control: no-store

{
  "access_token": "eyJhbGciOiJSUzI1NiIsImtpZCI6IjEifQ...",
  "token_type": "Bearer",
  "expires_in": 900,
  "refresh_token": "tGzv3JOkF0XG5Qx2TlKWIA",
  "scope": "openid appointments.read appointments.write profile"
}
Steps
  1. Hash the code_verifier in the SHA-256 Generator with Base64url output and compare it with the code_challenge.
  2. Compare the granted scope with the requested one.
  3. Check what OpenID Connect requires when openid is requested.
  4. Write four token-endpoint conditions with the expected status and error value.
Show the model answer
Model answer

The hash equals the challenge, so the pair is valid (it is the example in RFC 7636, Appendix B).

appointments.write and profile were granted but neither requested nor approved: a least-privilege defect unless a documented server policy explains it.

No id_token came back although openid was requested, so the client cannot tell who signed in; OpenID Connect requires it here. expires_in (15 minutes) and Cache-Control: no-store are as expected.

Each condition expects 400 with invalid_grant and no tokens: a code redeemed twice (then check that tokens from the first exchange are revoked, where supported); a wrong code_verifier; a redirect_uri that differs from the authorization request; an expired code.

Frequently asked questions

Is OAuth 2.0 an authentication protocol?

No. OAuth 2.0 is a framework for delegated authorization: it lets a client obtain an access token for an API. It does not define how the client learns who the user is. OpenID Connect adds that with the ID token, which is why sign-in features should be built on OIDC rather than on OAuth 2.0 alone.

Why use PKCE when the client already has a client secret?

The secret authenticates the client but does not bind a particular authorization code to the request that produced it. PKCE does, which also blocks an attacker from injecting a stolen code. RFC 9700 therefore requires PKCE for public clients and recommends it for confidential clients as well.

Are the implicit and password grants still acceptable?

RFC 9700 says the implicit grant should not be used and the resource owner password credentials grant must not be used. If your product still relies on either, record it as a risk and ask the team for a migration plan to the authorization code grant with PKCE.

Can I practise these tests against a public identity provider?

Only within what that provider allows developers to do, and never with attack-style or high-volume tests. Run negative and security tests in your own test environment, with test users and a test client registration, or on systems you have written permission to test.

References

Practise with Mutqan tools

Decode OAuth 2.0 / OpenID Connect access and ID tokens and understand scopes, audiences and lifetimes.

Security

Generate mock login/token responses: access token, refresh token, expiry, user profile and OAuth2/OIDC shapes.

Mocking

Generate SHA-256 hashes of text — the standard for integrity checks and signatures.

Security

Test Bearer, Basic, API-key and custom-header authentication against an endpoint and compare authorised vs unauthorised responses.

API Testing

Check whether a JWT is expired, not yet valid, or how long remains until expiry.

Security

Go deeper

Your progress on this topic

Part of the QA Roadmap