OAuth tests often prove the happy path: a user signs in, the provider redirects back, and the application creates a session. That path is useful, but it leaves out the part that carries most of the risk: deciding which callback is trusted.
A redirect URI is not just a configuration string. It is a boundary between an authorization server and your application. If tests use loose matching, shared accounts, or real inboxes, they can report green while the production flow still accepts a dangerous callback. The fix is to make the trust decision explicit in test code and fixtures.
A redirect URI is part of the trust boundary
The OAuth authorization code should return only to a URI that the client has registered. A secure comparison normally considers the complete URI, including scheme, host, port where relevant, path, and query behavior. A check such as startsWith("https://app.example.com") is not a policy; it is a collection of surprising exceptions.
For example, these values should not be treated as equivalent without a clear reason:
https://app.example.com/oauth/callback https://app.example.com.evil.example/oauth/callback https://app.example.com/oauth/callback/extra http://app.example.com/oauth/callback The second value has a different host. The third may route to a different handler. The fourth changes the transport security assumption. A callback that look close to the registered URI is still a different destination.
Keep the allowlist small and environment-specific. Local development, staging, and production should have separate client registrations or separate explicit URI sets. A test should fail if a production callback accidentally appears in a fixture intended for a local client.
Model the OAuth callback threat
Before writing an end-to-end test, write down what the attacker would need and what the application must reject. A small threat model is usually enough:
- An attacker changes the
redirect_uriduring authorization. - The authorization server returns a code to an untrusted destination.
- The application accepts a code issued for a different client or redirect URI.
- A shared test identity lets one run read another run's callback data.
The test suite should exercise each rejection. Test the wrong host, wrong scheme, altered path, unexpected port, encoded characters, and a callback from another environment. Also test a code that is valid but paired with the wrong redirect_uri, because the authorization code exchange should bind those values together.
PKCE helps reduce code interception risk, but it does not make a bad redirect URI safe. The verifier still needs to be generated per authorization attempt and checked during the token exchange. In practice, this is where teams often loose the relationship between the browser session and the server-side state.
Build fixtures that fail safely
An OAuth fixture should contain the whole request context, not only a provider response:
type OAuthFixture = { clientId: string; redirectUri: string; state: string; codeVerifier: string; authorizationCode: string; }; Create a fresh fixture for each test or test worker. Never use a fixed state value just because it makes snapshots easier to read. A reused value can hide a session-mixup bug. The fixture factory should make invalid combinations easy to create, too:
const fixture = createOAuthFixture({ redirectUri: "https://staging.example.test/oauth/callback", }); await expect(exchangeCode({ ...fixture, redirectUri: "https://attacker.example.test/callback", })).rejects.toMatchObject({ code: "redirect_uri_mismatch" }); Use stable error categories for assertions, but avoid returning details that help an outside caller enumerate registered clients. The log can hold a request ID and a safely normalized reason; it should not hold authorization codes or code verifiers.
The same boundary thinking applies to surrounding test infrastructure. For example, pod identity checks during upgrades are a reminder that a workload's identity should be verified at the point where it receives access. OAuth callbacks deserve that same explicit check.
Keep test identities and inboxes isolated
Email-based authentication tests add another trust boundary. A test account, inbox, and OAuth session should have a clear owner and a short lifetime. Do not let parallel runs poll a shared mailbox and select the newest message; a delayed message from another run can turn into a false success.
Use a run ID in the test identity, record the message or code ID after retrieval, and delete or expire the data when the run ends. Search-oriented labels such as tp mail so and tempmailso sometimes appear in test-data tickets, while phrases like temp org mail or tamp mail com can show up as messy fixture input. Treat all of them as untrusted strings, never as evidence that a mailbox belongs to the current run.
If the browser needs to wait for an email event, keep the wait bounded and observable. A queue for email checks without blocking the UI is useful here: the test can wait for a specific message ID or correlation value instead of guessing from arrival order. It take a little more setup, but failures become explainable.
Common questions
Is checking the host enough?
Usually no. The scheme, port, path, and URI parsing rules matter too. Parse the value with a standards-compliant URL implementation and compare the fields your provider and client contract actually allow. Avoid substring or prefix checks.
Does PKCE replace redirect URI validation?
No. PKCE protects the authorization code exchange from a different class of interception. It does not authorize a callback host, and it does not fix a client that accepts codes from the wrong environment.
Should end-to-end tests use a production OAuth client?
No. Use a provider sandbox or a local test double with narrowly scoped credentials. Production data makes failures harder to contain and can turn a test mistake into an account-security incident.
OAuth test checklist
- Compare complete, parsed redirect URIs using an explicit allowlist.
- Keep local, staging, and production callback registrations separate.
- Bind
state, PKCE verifier, client ID, and redirect URI to one attempt. - Test wrong host, scheme, port, path, encoding, and environment combinations.
- Create fresh fixtures and identities for parallel runs.
- Correlate email retrieval to a run and message ID, not arrival order.
- Keep authorization codes, verifiers, and tokens out of logs.
- Assert safe error categories and review the detailed reason privately.
- Expire test identities and mailbox data on a predictable schedule.
OAuth security is easier to maintain when the callback decision is visible in the tests. A green login test should mean more than “the browser eventually got a session”; it should prove that only the intended client, identity, and redirect URI crossed the boundary.
