A pull request lands on Friday afternoon. The diff looks reasonable: a callback handler refactor, some cookie utility cleanup, one new middleware branch to support a second identity provider. CI is green. Unit tests pass. The reviewer checks the auth callback URL logic, sees coverage on the state validation helper, and approves.
By Monday morning, new users cannot sign in.
Not all of them. Just enough to make the incident confusing.
Some users hit an infinite redirect between /login and /auth/callback. Some authenticate with the provider, get bounced back to the app, and immediately lose their session. Some complete consent, then land on a generic error page because the original state or PKCE verifier is gone. Internal QA cannot reproduce it locally because localhost uses a different cookie policy and skips the CDN layer. Backend logs say tokens were exchanged successfully. Frontend monitoring shows repeated navigations. The PR still looks correct.
This is what modern auth failures look like: not a broken function, but a broken workflow.
And that distinction matters more now than it used to. AI-generated code can assemble plausible OAuth handlers, route guards, and callback parsing logic fast enough to keep teams shipping. But it also increases the volume of code that appears correct in isolation while failing under real browser conditions. Traditional testing, especially PR-centric testing, is optimized for code paths. Authentication reliability depends on user journeys.
That is the verification gap: code changes look safe in review, pipelines stay green, but signup and login workflows fail in production because nothing in the PR process executed the full auth journey in a real browser.
The auth bug that keeps passing review
OAuth redirect loops are one of the best examples of why green CI can be meaningless.
A redirect loop usually emerges from a mismatch between steps, not from one obviously broken line of code. The sequence looks like this:
- User visits a protected route.
- App redirects to the identity provider.
- Provider authenticates the user.
- Provider redirects back with
codeandstate. - App validates
state, restores temporary auth context, exchanges the code, sets session cookies, and redirects to the original page. - App fails to recognize the user as authenticated.
- Route guard sends the user back to login.
- Repeat.
Nothing in that list is particularly exotic. Every step is individually testable. That is exactly why teams miss it.
You can unit test validateState(). You can unit test exchangeCodeForToken(). You can integration test the callback controller with a mocked request. You can assert that a Set-Cookie header exists. You can even verify that your route guard redirects unauthenticated users.
And still miss the fact that the browser never sends the cookie back because of domain or SameSite configuration. Or that your callback route runs on auth.example.com while the app expects the session on app.example.com. Or that the state param lives in a short-lived in-memory store reset by a deploy. Or that a proxy normalizes the callback URL differently from your local environment. Or that Safari and Chrome behave differently around third-party contexts.
The loop exists in the system between components. PR review tends to reason about components.
Why unit tests miss OAuth failures
Unit tests are good at determining whether a function behaves correctly for explicit inputs. OAuth failures are often caused by hidden inputs:
- browser cookie behavior
- redirect timing
- origin transitions
- secure flag handling
- path and domain scoping
- provider-specific callback quirks
- environment-dependent URLs
- state persistence across multiple requests
Those are not natural unit test boundaries.
Consider a state validation helper in JavaScript:
jsexport function validateState(receivedState, expectedState) { if (!receivedState || !expectedState) return false return receivedState === expectedState }
That function can have perfect test coverage and still be operationally useless.
jsimport { validateState } from './auth' test('returns true when states match', () => { expect(validateState('abc', 'abc')).toBe(true) }) test('returns false when state is missing', () => { expect(validateState('abc', null)).toBe(false) })
Great. But where does expectedState come from in production?
- A signed cookie?
- Redis?
- sessionStorage?
- a server-side session?
- edge middleware?
How long does it live?
What happens if the user opens multiple login tabs?
What happens if the callback lands on a different subdomain?
What happens if the app server handling /login is not the same instance handling /auth/callback and there is no shared store?
The meaningful failure is not “does equality work?” It is “does the browser complete the round trip and return enough context for the app to finish authentication?”
Unit tests rarely answer that.
Mocked callbacks create false confidence
The most common “integration” test for OAuth is not actually a browser auth flow. It is a server test that directly calls the callback endpoint with a fake code and state.
Something like this:
jsimport request from 'supertest' import { app } from '../app' describe('oauth callback', () => { test('sets session and redirects to dashboard', async () => { const res = await request(app) .get('/auth/callback?code=fake-code&state=test-state') .set('Cookie', ['oauth_state=test-state']) expect(res.status).toBe(302) expect(res.headers.location).toBe('/dashboard') expect(res.headers['set-cookie']).toBeDefined() }) })
This test can be useful. It can catch parsing bugs, token exchange errors, or missing redirects.
But it also creates a dangerous illusion: it replaces the browser with a handcrafted request that has exactly the headers and cookies your server hopes to see.
Real browsers are much less cooperative.
A mocked callback will not tell you:
- whether the cookie was originally written with a domain the browser rejects
- whether
SameSite=LaxorSameSite=None; Secureis required for your flow - whether an HTTPS-only cookie disappears in preview environments
- whether the callback URL differs after passing through your CDN or ingress
- whether your app performs a client-side auth check before the cookie becomes visible
- whether your frontend guard races the session establishment and re-triggers login
In other words, mocked callback tests validate your assumptions. Production validates browser reality.
When those diverge, CI stays green and users get stuck.
Why happy-path integration tests still fail you
Some teams go one step further and add high-level integration tests that simulate auth success without visiting the real provider. They may stub the callback or inject a session token. This is often done for speed, reliability, or to avoid external dependencies.
Again, useful—but incomplete.
The issue is not that these tests are bad. The issue is that teams mistake them for workflow verification.
A happy-path auth integration test usually checks one of two things:
- Can the app behave as an authenticated user if a valid session already exists?
- Can the callback handler succeed if the correct data is delivered?
Neither verifies the full journey:
- protected page -> login initiation -> provider redirect -> callback -> session establishment -> post-login navigation -> authenticated page state
That full journey is where auth breaks.
It is also where AI-generated code is especially risky. Generated code often gets the obvious framework hooks right while subtly missing cross-request and cross-origin behavior. It knows how to parse a callback. It does not “know” your cookie topology, your reverse proxy headers, your preview domain strategy, or how your SPA route guard interacts with server-rendered session state.
That is why auth regressions survive both code review and green pipelines. Everyone is testing the pieces. Nobody is testing the journey.
The specific failures PR testing misses
Let’s get concrete. Here are the classes of OAuth failures that routinely escape PR checks.
Redirect loops caused by session cookies not sticking
This is the classic case.
Your callback exchanges the authorization code, creates a session, and responds with a redirect. The server log says success. But the browser does not send the session cookie on the next request, so the app thinks the user is anonymous and redirects back to login.
Common causes:
- cookie domain does not match the app host
- cookie path is too narrow
Secureis set but the environment is plain HTTPSameSitepolicy blocks the cookie for the callback flow- multiple cookies with the same name exist across subdomains
Example Node/Express bug:
jsres.cookie('session', sessionToken, { httpOnly: true, secure: true, sameSite: 'lax', domain: 'app.example.com', path: '/auth' }) res.redirect('/dashboard')
This looks tidy. It is also broken for most apps because the cookie is scoped to /auth, so the browser will not send it to /dashboard.
A test that only inspects Set-Cookie existence will pass. A browser journey test will fail immediately.
A more realistic config:
jsres.cookie('session', sessionToken, { httpOnly: true, secure: process.env.NODE_ENV !== 'development', sameSite: 'lax', domain: '.example.com', path: '/' })
Even this can fail depending on your subdomain strategy and preview environments. The point is not that there is one universal correct cookie setting. The point is that only a real browser flow tells you whether your chosen settings actually work.
State or PKCE verifier loss across the redirect
Another frequent failure: the login starts correctly, but the callback cannot validate state or complete PKCE because the app lost temporary auth context.
A Python example:
pythonfrom flask import session, redirect, request @app.route('/login') def login(): state = generate_state() verifier = generate_pkce_verifier() session['oauth_state'] = state session['pkce_verifier'] = verifier return redirect(build_provider_url(state, verifier)) @app.route('/auth/callback') def callback(): if request.args.get('state') != session.get('oauth_state'): return 'invalid state', 400 verifier = session.get('pkce_verifier') tokens = exchange_code(request.args['code'], verifier) establish_session(tokens) return redirect('/dashboard')
Looks fine. But what if:
- the session store is memory-backed and deploys restart the instance
- callback requests land on a different instance without sticky sessions
- an edge layer strips or rewrites cookies
- the user spends too long on the provider consent screen and session expiry is too aggressive
- opening a second login tab overwrites the first state
None of those will show up in a direct callback unit test.
Cross-origin and subdomain mismatch
A lot of auth architectures split concerns across hosts:
www.example.comapp.example.comauth.example.com- provider domain like
accounts.google.com
Each handoff is a chance to lose state, violate CORS expectations, or mis-scope cookies.
A common pattern is starting auth from one origin and finishing on another. Maybe marketing pages live on www, app shell lives on app, and auth callback is hosted separately. In code review, each redirect URL may look valid. In production, the session may be written for the wrong host or never become visible to the SPA running elsewhere.
This gets worse in preview deployments where URLs are dynamic:
feature-123.company-preview.devauth.company-preview.dev
If the callback whitelist, cookie domain, or base URL inference lags behind deployment reality, only real end-to-end login exposes it.
Frontend route guards racing the backend
This class of bug is underappreciated because backend logs look healthy.
The callback succeeds. The server sets the cookie. It redirects to the app. Then the frontend boots, checks auth status too early, sees no user yet, and pushes the browser back to /login before the session hydration completes.
Example React guard:
jsuseEffect(() => { if (!auth.loading && !auth.user) { router.push('/login') } }, [auth.loading, auth.user])
If auth.loading flips false before the post-callback session fetch completes, this guard can trigger a loop. Your unit tests for the hook might pass. Your server callback tests might pass. Your browser flow is still broken.
Provider and environment differences
Authentication code often behaves differently across environments even when the application code is identical.
Examples:
- localhost uses
http://localhostand relaxed cookie assumptions - staging runs behind a proxy that changes
X-Forwarded-Proto - production enables HSTS and secure cookies
- one provider normalizes redirect URIs differently than another
- preview deployments are not in the provider allowlist
This is another place where AI-generated code can mislead teams. Generated implementations often assume clean, single-environment behavior. Real auth depends on deployment topology.
CI usually runs in the cleanest environment you have.
Production rarely is.
Why QA alone does not solve this
A common response is: “Our QA team tests login manually.”
That is better than nothing, but it is not enough for a few reasons.
First, manual QA is not reliable coverage for every PR. It usually happens later, less frequently, or only on release candidates. That still leaves a large verification gap at PR time.
Second, auth failures can be environment-specific and intermittent. One tester using one browser profile on one network may not reproduce what actual users hit.
Third, login is often treated as a smoke check rather than a fully instrumented workflow. QA might confirm they can log in, but not inspect whether there were transient loops, cookie retries, silent callback failures, or role-specific onboarding regressions.
Fourth, manual verification does not scale with the volume of change produced by modern teams and AI-assisted development. If more code is being shipped, the answer cannot be “hope a human clicks through enough flows.”
Manual QA is a supplement. It is not a replacement for executable workflow verification.
The core insight: auth reliability is a browser workflow problem
The important shift is this: stop thinking of OAuth as a backend integration you can validate at the API layer.
It is a browser workflow spanning:
- frontend route guards
- server session creation
- cookies
- redirects
- third-party origins
- deployment-specific hostnames
- asynchronous state restoration
That means the primary correctness criterion is not “did the callback handler return 302?”
It is “can a real browser start unauthenticated, complete the full login journey, and end up authenticated on the intended page without looping, losing state, or crossing origins incorrectly?”
Until that exact question is tested automatically, your PR checks are not verifying auth. They are verifying fragments.
What real verification looks like
For auth-critical paths, you need action-level tests that execute the journey the way a user does.
In practice, that means browser automation.
Playwright is a strong fit because it gives you control over navigation, cookies, storage state, and network inspection. For teams on Python, you can still drive equivalent flows with Playwright’s Python bindings or Selenium, but the principle is the same.
The test should:
- start logged out
- visit a protected route
- trigger login
- complete provider authentication in a controlled test setup
- return through the real callback URL
- assert authenticated landing state
- assert no loop occurred
- inspect cookies, URL transitions, and visible user state
There are two ways to do this:
- use a dedicated test identity provider or provider sandbox
- stub the provider while preserving the real browser redirect chain and callback behavior
The second approach is often more stable in CI, but the key is that you still execute the browser-level round trip.
Example Playwright test for full auth journey
Here is a simplified Playwright example in JavaScript:
jsimport { test, expect } from '@playwright/test' test('user can log in through oauth flow and reach dashboard', async ({ page, context }) => { const visited = [] page.on('framenavigated', frame => { if (frame === page.mainFrame()) { visited.push(frame.url()) } }) await page.goto('https://app.example.com/dashboard') await expect(page).toHaveURL(/\/login/) await page.getByRole('button', { name: 'Continue with Test Provider' }).click() await expect(page).toHaveURL(/auth-provider|accounts|oauth/) await page.fill('input[name="email"]', 'test-user@example.com') await page.fill('input[name="password"]', 'correct-horse-battery-staple') await page.getByRole('button', { name: 'Sign in' }).click() await expect(page).toHaveURL(/app\.example\.com\/auth\/callback|app\.example\.com\/dashboard/) await expect(page).toHaveURL('https://app.example.com/dashboard') await expect(page.getByText('Welcome back')).toBeVisible() const cookies = await context.cookies('https://app.example.com') const sessionCookie = cookies.find(c => c.name === 'session') expect(sessionCookie).toBeTruthy() expect(sessionCookie.path).toBe('/') expect(visited.filter(url => url.includes('/login')).length).toBeLessThan(2) })
This does something your server-side tests do not: it verifies that the browser actually made it through the journey and retained the session.
You can make this stronger by asserting redirect counts, checking callback query params disappear after success, and tracing network requests to /api/me or equivalent session endpoints.
A Python Playwright example
If your team is more Python-centric:
pythonfrom playwright.sync_api import sync_playwright, expect def test_oauth_login_flow(): with sync_playwright() as p: browser = p.chromium.launch() context = browser.new_context() page = context.new_page() visited = [] page.on("framenavigated", lambda frame: visited.append(frame.url) if frame == page.main_frame else None) page.goto("https://app.example.com/dashboard") expect(page).to_have_url(lambda url: "/login" in url) page.get_by_role("button", name="Continue with Test Provider").click() expect(page).to_have_url(lambda url: "oauth" in url or "accounts" in url) page.locator('input[name="email"]').fill('test-user@example.com') page.locator('input[name="password"]').fill('correct-horse-battery-staple') page.get_by_role("button", name="Sign in").click() expect(page).to_have_url("https://app.example.com/dashboard") expect(page.get_by_text("Welcome back")).to_be_visible() cookies = context.cookies("https://app.example.com") session_cookie = next((c for c in cookies if c["name"] == "session"), None) assert session_cookie is not None assert session_cookie["path"] == "/" assert len([u for u in visited if "/login" in u]) < 2 browser.close()
Again, the goal is not just “did the callback endpoint work?” It is “did the user end up logged in?”
Add failure-oriented assertions, not just success assertions
Teams often write one success assertion and stop there. For auth, that is not enough.
You should explicitly test for failure signatures:
- repeated visits to
/login - repeated visits to
/auth/callback - session cookie missing after callback
- state validation error rendered to the user
- callback query params still present after final navigation
- frontend
401on session bootstrap request
Example:
jsconst callbackVisits = visited.filter(url => url.includes('/auth/callback')).length expect(callbackVisits).toBe(1) const loginVisits = visited.filter(url => url.includes('/login')).length expect(loginVisits).toBeLessThanOrEqual(1)
This catches the exact class of issue that “user sees dashboard eventually” can miss, especially when your app recovers after an unnecessary loop.
CI/CD needs workflow gates, not just build gates
If authentication is business-critical, your CI/CD pipeline should treat workflow tests as release gates.
A minimal GitHub Actions example:
yamlname: auth-workflow-tests on: pull_request: push: branches: [main] jobs: test-auth-flow: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 20 - run: npm ci - run: npx playwright install --with-deps - name: Start app run: | npm run build npm run start & npx wait-on http://localhost:3000 - name: Run auth workflow tests env: BASE_URL: http://localhost:3000 TEST_AUTH_EMAIL: ${{ secrets.TEST_AUTH_EMAIL }} TEST_AUTH_PASSWORD: ${{ secrets.TEST_AUTH_PASSWORD }} run: npx playwright test tests/auth-flow.spec.ts - name: Upload trace on failure if: failure() uses: actions/upload-artifact@v4 with: name: playwright-traces path: test-results/
This is still only useful if the environment resembles reality enough to exercise the meaningful boundaries. For many teams, the right pattern is:
- fast unit and integration tests on every PR
- browser auth workflow tests on PR for auth-related changes
- full cross-environment auth smoke on deploy
That last part matters. CI against localhost is better than nothing, but not enough if your real risk is cookie scope, proxy headers, or preview domain mismatch.
Tools comparison: what each layer catches
Here is the blunt version.
Unit tests
Good for:
- parsing logic
- state helper functions
- token exchange error handling
- route guard condition logic
Bad at:
- cookie behavior
- redirects
- origin transitions
- browser timing
- provider interactions
Server-side integration tests
Good for:
- callback endpoint behavior
- session creation logic
- redirect responses
- error handling with realistic app wiring
Bad at:
- actual browser cookie persistence
- cross-origin flow correctness
- frontend-backend auth synchronization
Mocked end-to-end tests with injected sessions
Good for:
- testing app behavior after authentication
- broad product coverage without repeated login cost
- developer productivity for non-auth flows
Bad at:
- validating login itself
- catching redirect loops
- detecting callback and state management breakage
Manual QA
Good for:
- exploratory debugging
- browser-specific observations
- catching obvious production issues when someone remembers to check
Bad at:
- consistency
- PR-time enforcement
- scaling with change volume
Real browser workflow tests
Good for:
- login/signup verification
- cookie and redirect correctness
- cross-origin workflow validation
- catching auth regressions before deploy
Bad at:
- speed compared to lower-level tests
- setup complexity
- dependence on stable test environments
That tradeoff is worth it. Not for every route in your app, but absolutely for authentication.
Practical debugging techniques when loops happen
When you do hit a redirect loop, debug it like a workflow problem.
-
Capture the full navigation chain.
- login page
- provider URL
- callback URL
- post-callback URL
- next redirect back to login
-
Inspect cookies before and after callback.
- Was
Set-Cookiereturned? - Did the browser store it?
- Is path/domain correct?
- Is it sent on the next request?
- Was
-
Compare server success logs with browser-authenticated state.
- token exchange success does not equal user session success
-
Record the request that determines login state.
/api/me/session- SSR user bootstrap
-
Test on the real deployment topology.
- subdomains
- HTTPS
- proxies
- preview URLs
Playwright traces are particularly useful here because they show page navigations, network requests, console output, and screenshots in one timeline. For debugging and developer productivity, this is much more actionable than reading isolated unit test failures.
Actionable practices that close the verification gap
If you want fewer “passed CI, broken login” incidents, adopt these practices.
1. Define auth as a critical user workflow
Treat signup, login, logout, password reset, and account linking as release-critical paths. They are not just features. They are entry points to the product.
2. Add one true end-to-end auth test per major flow
At minimum:
- new user signup
- existing user login
- logout
- session persistence after redirect
If you support multiple providers, each major provider path needs coverage.
3. Run auth workflow tests when auth-adjacent code changes
Do not limit this to files in auth/. Changes in middleware, cookies, proxy config, frontend guards, environment URL resolution, or deployment config can all break login.
4. Assert against loop signatures explicitly
Do not just assert destination. Count login and callback visits. Assert cookie presence. Assert authenticated API bootstrap succeeds.
5. Test in an environment with real domain and HTTPS behavior
Localhost auth tests are useful, but many failures only appear under real hostnames and TLS.
6. Keep provider coupling under control
Use a dedicated test app registration or test identity provider account. If using stubs, preserve the real browser redirect and callback mechanics.
7. Make failures debuggable
Store traces, screenshots, console logs, and network recordings from failed auth tests. If the test only says “expected dashboard,” you will waste hours.
8. Separate “authenticated app tests” from “authentication tests”
Inject sessions for broad application coverage. Use real auth journeys for auth verification. Do not confuse the two.
9. Review auth changes with deployment topology in mind
In PR review, ask:
- what host sets the cookie?
- what host reads it?
- what path/domain applies?
- what happens in preview environments?
- what survives cross-origin redirects?
These are better review questions than “does the helper have tests?”
10. Assume AI-generated auth code is plausible, not proven
This is the operational mindset teams need now. Generated auth code often looks polished. That does not make it verified. The more code is produced quickly, the more important workflow testing becomes.
Conclusion
OAuth redirect loops are not embarrassing edge cases. They are predictable outcomes of testing the wrong thing.
A PR can pass. CI/CD can stay green. Unit tests can show full coverage. Review can look careful. And the first real login can still fail because authentication is not a single code path—it is a multi-step browser workflow crossing origins, cookies, redirects, and timing boundaries.
That is why the verification gap keeps widening in modern teams. We have gotten very good at checking code quality inside the application boundary. We are still too willing to assume that browser workflows will hold together if the parts look correct.
They often do not.
If login matters to your business, test it the way users experience it. Run action-level browser tests that execute the full auth journey. Gate releases on those tests. Instrument them for debugging. Use lower-level tests to support them, not replace them.
Because “passed in PR” is not the same thing as “works in production.” And nowhere is that more obvious than the auth flow that loops right after a green build.
