13 November 2026

OAuth Security Testing: Login, Consent and Token Boundaries

OAuth Security Testing: Login, Consent and Token Boundaries
14 min. readBy Borg

OAuth Security Testing: Login, Consent and Token Boundaries

OAuth security testing means proving that each protocol binding rejects what it should reject: the redirect, the login transaction, the authorisation code, the issuer, the audience, the scope and the refresh token. A 200 or a redirect proves nothing on its own. Evidence is the final session identity, the granted scope and what a resource server actually did with the token.

Imagine a team that runs a working login flow. The happy path passes every test, and a scanner reports nothing. Yet nobody has checked what the callback does with a state value it did not issue, or whether the API accepts an ID token as an access token. This guide turns those questions into a negative acceptance matrix. It is written for teams testing their own client, authorisation server integration or resource server, with written permission, on staging.

This is not a generic API guide, and it is not tenant-isolation testing. For object-level access see IDOR testing for APIs; for opaque long-lived credentials see API key security testing. Here the subject is the protocol itself.

Where OAuth testing starts: inventory, flows and what you are allowed to touch

OAuth testing starts with an inventory of who plays which role, because the controls differ by role and by client type. Write down every client (web backend, SPA, mobile, CLI), every authorisation server or issuer you trust, every registered redirect URI, and every resource server that accepts tokens.

Then separate two jobs. OAuth 2.0 is delegated authorisation: a client obtains an access token to call an API. OpenID Connect adds authentication on top, through the ID token (OpenID Connect Core 1.0). So the answer to "does OAuth 2.0 provide authentication?" is no, not by itself. A login button built on a bare access token is a design question to test, not a given.

Next, list the flows in use. The authorisation code flow with PKCE is the default to test. The implicit grant is deprecated by RFC 9700 and the resource owner password credentials grant MUST NOT be used (RFC 9700). Record any legacy grants, assess which clients can use them and review the deployment against the applicable RFC guidance before testing.

Finally, scope the permission. Test only systems you own or hold written authorisation for. Use staging, dedicated test accounts and a low request rate. Don't touch real customer data, production credentials, or third-party identity provider tenants you don't control.

Map the four boundaries: browser, callback, token endpoint and resource server

Every OAuth control lives at one of four boundaries, and testing at the wrong one gives false comfort.

Browser. The user agent carries the authorisation request and the response. Checks here: which parameters leave the app, whether the app exposes an open redirector, and whether the response lands only on a registered URI.

Callback. Your client receives code and state (and iss where the issuer supports it). This is where state, the PKCE verifier and the nonce are bound to the login transaction the browser started. A callback that accepts a response for a transaction it never began is the classic login CSRF.

Token endpoint. The code is exchanged, with client authentication for confidential clients and code_verifier for PKCE. Checks: single use, binding to the client and redirect URI, and verifier match.

Resource server. The API decides what a token may do. Checks: issuer, audience, token type, expiry and scope enforcement. The OWASP cheat sheet stresses audience-restricted tokens and privilege-restricted scopes (OWASP OAuth 2.0 Cheat Sheet).

One point trips teams: tokens come in different shapes. If access tokens are JWTs, the resource server can validate signature, iss, aud and exp locally. For opaque tokens, validate them through the trusted server-side mechanism your deployment uses, such as token introspection, rather than treating them as JWTs. Don't prescribe JWT-only validation for opaque tokens, and don't test an opaque-token API as if it parsed claims.

Set up a safe test rig: test accounts, isolated client, controlled callbacks, dummy data

A safe rig lets you send malformed requests without touching anything real. Build four things before the first negative test.

  • Test accounts. At least two ordinary users and one admin-equivalent, all created for this exercise. Two users let you check that a code or token minted for one never yields a session for the other.
  • An isolated test client. Register a separate client on a staging issuer, with its own client_id, a small set of registered redirect URIs and a deliberately narrow scope list. A second, differently registered client lets you test client binding of codes and audiences.
  • Controlled callbacks. Every redirect URI you register, and every host you point a test at, is under your team's control. Use a local listener or a staging host that only logs. Never point a test at an endpoint you don't own.
  • Dummy data. Seed the staging API with synthetic records so a successful call proves reach, not exposure.

Run a positive baseline first: a clean login, a clean token exchange, a clean API call. Record the expected session identity, granted scope and API response. Every negative test is a diff against that baseline.

Handle evidence carefully. Never log real tokens; redact them to a prefix and length. Capture the request, the response status, the issuer and API log lines, and the resulting session state. Keep volume low and stop if staging shows strain.

Login and redirect checks: exact redirect_uri matching, state, nonce and PKCE

Login checks verify that a response can only complete the transaction that started it. Each control binds something different, so test each one for what it protects.

Exact redirect_uri matching. RFC 9700 says authorisation servers MUST use exact string matching when comparing redirect URIs against pre-registered values, with a documented exception for port numbers on localhost for native apps (RFC 9700). Test against your staging issuer: a registered URI with an extra path segment, a changed query string, a different subdomain, a trailing slash difference and a case change. Expected result: the authorisation request is rejected at the issuer and the browser is never redirected to the altered value. Evidence is the issuer's error page or log line, not the absence of a visible redirect. Also check your own app for open redirectors, since the cheat sheet warns clients and servers must not expose URLs that forward the browser to arbitrary query-supplied targets.

state. The client should bind each authorisation request to the user agent that started it. Test a callback with a missing state, a state from a different browser session, and a replayed state from a completed login. Expected: the callback is refused and no session is created. A 302 to the home page proves nothing; check whether a session cookie was issued and for whom.

PKCE. RFC 9700 requires clients to prevent authorisation code injection, and PKCE is the usual mechanism; public clients must use it. Test a token request with no code_verifier, a wrong one, and a verifier from a different transaction. Expected: the token endpoint refuses. Also test downgrade: if the request omitted code_challenge, the server must not then accept a bare code exchange when your policy requires PKCE. Check that the issuer supports S256 and that your client does not use plain without a reason.

nonce (OIDC). The nonce ties the ID token to the authentication request. Test an ID token whose nonce is absent or differs from the one the client sent. Expected: the client rejects it (OpenID Connect Core 1.0).

A caution on how these relate. PKCE, state and nonce are not interchangeable, and not all are universally mandatory for every threat. PKCE defends against code injection, state against login CSRF where PKCE is not applied, and nonce against ID token replay. Test the ones your architecture relies on, and document why you rely on them.

Code, issuer, audience and token-type checks: what the client and API must reject

These checks prove that a credential is only honoured by the party and for the purpose it was issued.

Authorisation code. A code must be single use and bound to the client and redirect URI it was issued for. Test: exchange the same code twice; exchange a code with a different registered client's credentials; exchange it with a different redirect_uri. Expected: code reuse, redemption by the wrong client and an inconsistent redirect URI are each refused. A secure issuer also treats reuse as a signal, so check issuer logs for how reuse was recorded.

Issuer confusion. If your client talks to more than one issuer, a response from the wrong one must not be accepted. The OWASP cheat sheet and RFC 9700 describe mix-up attacks and recommend countermeasures such as validating the iss value in the authorisation response, or using distinct redirect URIs per issuer. Test on staging with two test issuers: return a response whose iss does not match the issuer the transaction began with. Expected: rejected before the code is exchanged. For ID tokens, the iss claim must match the issuer too (OpenID Connect Core 1.0).

ID token validation. The client must verify signature, iss, aud (containing its own client_id), expiry and, where used, nonce. Test tokens with a wrong audience, an expired time and an unsigned or wrongly signed body, all minted by your test issuer. Expected: each is rejected.

Audience and token type at the resource server. An ID token is a statement to the client about an authentication event. It is not an API credential. Send the ID token to your API as a bearer token. Expected: rejected. Then send an access token issued for API A to API B, and a token with the right issuer but the wrong aud. Expected: rejected. For JWT access tokens this is claim validation; for opaque tokens it is what introspection returns. This is the core of JWT access token security testing: confirm the API checks iss, aud, exp, signature algorithm and token type rather than only parsing the payload.

If the access tokens are sender-constrained (mTLS or DPoP), also test that a token presented without the matching proof is refused. The cheat sheet covers these proof-of-possession mechanisms as options, not requirements for every deployment.

Consent, scope and refresh token checks

Consent and refresh tests verify that a grant cannot grow or outlive what the user approved.

Consent and scope. RFC 9700 says access token privileges should be restricted to the minimum required for the use case. Test on staging: request a scope the test client is not registered for; request a scope the user did not consent to on a repeat login; and remove a scope between requests. Expected: the issuer either refuses or issues a token without the extra scope, and the API enforces the scope it sees. Then call a sensitive endpoint with a token missing the needed scope. Expected: refused. The evidence is the granted scope in the token or introspection response plus the API's decision, since a consent screen that looks right can still sit in front of an over-broad token.

Incremental consent. If your app asks for more scope later, check the old token does not silently gain it.

Refresh tokens. RFC 9700 says refresh tokens for public clients MUST be sender-constrained or use refresh token rotation. Rotation is one acceptable option, not the only one, and confidential clients rely on client authentication. Test the option you chose. With rotation: use a refresh token, then present the old one again. Expected: refused, and a well-configured issuer may revoke the descendant chain; check logs for that. With sender constraint: present the refresh token without the bound key. Expected: refused. Also test that a refresh token issued to one client cannot be redeemed by another, and that it cannot widen scope beyond the original grant.

Keep this to protocol binding. Lifetime policy and revocation of long-lived credentials are a separate exercise, covered for opaque keys in the API key security testing guide.

One policy test belongs here too: account linking. With authorised test identities, check that a login from the issuer with an unverified email cannot attach itself to an existing local account. Test the rule, with bounded test identities, and nothing more.

The acceptance matrix: control, test layer, expected rejection, evidence and limits

The matrix is the artefact that makes this repeatable: for each control, what must be rejected and what evidence proves it. Treat "the response was a 200 or a redirect" as no evidence at all. Use this as a checklist for OAuth 2.0 security testing, and adapt rows to your architecture.

ControlTest layerExpected rejectionEvidenceLimits
Exact redirect_uri matchIssuer, browserAltered path, query, host or case refused before any redirectIssuer error or log line; no redirect to altered URILocalhost port exception for native apps; custom schemes vary by platform
state bindingCallbackMissing, foreign or replayed state refusedNo session cookie issued; client log of refusalPKCE may cover CSRF where the client relies on it
PKCEToken endpointMissing or wrong code_verifier refused; no downgradeToken endpoint error; issuer logClient authentication does not replace transaction binding; RFC 9700 recommends PKCE for confidential clients too
nonceCallback, ID tokenID token with absent or mismatched nonce rejectedClient validation logOIDC only; not an OAuth-only control
Code single use and bindingToken endpointSecond use, wrong client or wrong redirect refusedToken endpoint error; reuse recorded in issuer logDepends on issuer behaviour on reuse
Issuer (iss) checkCallback, ID tokenResponse or token from the wrong issuer refusedClient log naming the mismatchRelevant when more than one issuer is trusted
ID token claimsClientWrong aud, expired, bad signature rejectedClient validation errorDoes not apply to opaque access tokens
Audience and token type at APIResource serverID token as bearer; token for another API refusedAPI 401 or 403 with reason in server logJWT: claim checks; opaque: introspection result
Scope and consentIssuer, APIUnregistered or unconsented scope not granted; API enforces scopeGranted scope in token or introspection; API decisionConsent UI alone is not evidence
Refresh token reuseToken endpointReused rotated token refused, or unbound token refusedIssuer log of refusal and any chain revocationRotation or sender constraint; pick one and test it
Sender constraint (if used)Token endpoint, APIToken without matching proof refusedmTLS or DPoP error in server logOnly applies where deployed

Two columns carry the weight. "Evidence" forces you to look at logs and final state instead of the browser. "Limits" keeps the matrix honest: a row that does not apply to your architecture is marked not applicable with a reason, not silently dropped.

What to do with OAuth tests on every PR and release

Split the matrix by what each stage can actually establish. Pull request review can read handler and configuration changes: a new redirect URI pattern, a loosened audience check, a state comparison that became optional, a new scope added to a client. These are diff-level facts, and a reviewer or a code-aware tool can flag them before merge. For how that works in practice see PR security review tools compared.

Runtime behaviour is a different matter. Whether the issuer actually rejects a mismatched redirect, whether the API actually refuses an ID token, whether a reused refresh token is actually refused: only a running system answers that. Automate the negative rows against staging in CI where you can, using your isolated client and dummy data. Run the full matrix on a release cadence and whenever the issuer, client library or token format changes.

A few operating rules help. Pin the expected outcome per row so a regression shows as a failing test with the log line attached. Re-run the relevant rows after any fix, as described in security regression testing for authorisation fixes. Keep a short list of rows that need a person, such as account-linking policy and consent wording. And remember the point of security testing on every release: the matrix should run without anyone scheduling it.

On tooling, a proxy such as Burp Suite is a common way to intercept and modify the authorisation request and callback during manual checks, and an OAuth playground is handy for understanding the flow. Neither replaces the matrix. They help you send the request; the matrix tells you what the answer must be. The OWASP cheat sheet and RFC 9700 are the reference for what to expect, and RFC 9700 is the current security best current practice for OAuth 2.0.

Test the rejections, not the happy path

OAuth fails at its bindings, and the bindings only show up when you send the request that should be refused and read the evidence. Build the matrix, run the negative rows on staging with an isolated client, and treat a redirect or a 200 as unproven until session identity, scope and API behaviour say otherwise. If you want the code-aware checks on handler and config changes in every pull request, and a whitebox pentest that validates auth flows against the running application with evidence and reproduction steps, book a walkthrough with Borg at borghq.io.

Frequently asked questions

Does OAuth 2.0 provide authentication?
Not by itself. OAuth 2.0 is delegated authorisation: it lets a client obtain a token to call an API. OpenID Connect adds authentication on top, through the ID token, with checks on issuer, audience, signature and nonce. Using a bare access token as proof of who logged in is a design flaw worth testing for.
How secure is OAuth?
Secure when implemented to current guidance, weak when bindings are skipped. RFC 9700 sets the best current practice: exact redirect URI matching (with a localhost port exception for native apps), PKCE for public clients, deprecation of implicit and password grants, and sender-constrained or rotated refresh tokens for public clients. Most real failures are missing checks in the client or API, which a negative test matrix exposes.
What is the OAuth 2.0 security testing checklist?
Test exact redirect_uri matching, state binding, PKCE and nonce where applicable, single-use and client-bound codes, issuer checks, ID token and access token audience, token type, scope enforcement, and refresh token reuse. For each, define the expected rejection and the evidence, such as issuer logs and final session identity, not just a status code.
What are common OAuth login vulnerabilities in SaaS?
Loose redirect URI matching, missing or unchecked state, authorisation code injection without PKCE, ID tokens accepted as API tokens, wrong audience acceptance, issuer mix-up across multiple identity providers, over-broad scopes, and unprotected refresh token replay.
Is refresh token rotation required?
Not always. RFC 9700 says refresh tokens for public clients must be sender-constrained or use rotation. Either satisfies it. Confidential clients also rely on client authentication. Test whichever mechanism you chose: reuse of a rotated token should be refused, or a token without the bound key should be rejected.

Borg on
Social Media

Borg specializes in securing complex, high-risk digital systems.