Short answer: Keep callback ownership in your application, use tenant-scoped provider discovery, and complete an authorization-code handoff on the backend with PKCE, state, and nonce checks.
That decision protects session security without turning every sign-in into a support ticket.
I build email, SMS, and OTP flows, so I am suspicious of any auth diagram that skips delivery, retries, or audit trails. OAuth has a similar trap: the happy path is short, while the failure paths decide whether an enterprise rollout survives its first directory migration.
1. What should provider discovery, authorization handoff, and callback ownership guarantee?
Start with invariants. Discovery may identify an issuer from a verified tenant domain, but it must not silently trust a user-supplied redirect target. The handoff must preserve state and, when OpenID Connect is used, a nonce. Your callback must validate the response before it creates a local session. Those are boundaries, not implementation details.
The practical choice is to keep the browser as a transport and your backend as the policy engine. A backend-for-frontend (BFF) can hold the client secret, exchange the authorization code, validate the issuer and audience, then mint a short-lived application session. A public client can use PKCE, but it still needs a clear owner for account linking and session policy.
| Boundary | Safer default | Failure it contains |
|---|---|---|
| Provider discovery | Allow-list issuers per tenant; verify metadata over TLS | Login to a lookalike issuer |
| Authorization handoff | Authorization Code flow with PKCE, state, and nonce |
Code injection or login CSRF |
| Callback ownership | One backend endpoint validates and exchanges codes | Token leakage and split policy |
| Session issuance | Rotate session ID after login; set Secure, HttpOnly, SameSite cookies | Session fixation |
The catch is operational: a single callback owner becomes a dependency for every tenant. It is not suitable when a product must authenticate offline or in a device with no protected backend; use a public-client pattern with PKCE and document its narrower controls instead.
2. Seven numbered patterns for an enterprise handoff
- Discover by tenant, not by guess.
Map a verified email domain or administrator-selected tenant to an issuer. Cache metadata briefly, and keep the mapping auditable. Do not derive an issuer URL by concatenating arbitrary input; an attacker can turn that into account capture.
- Make the redirect URI boring.
Register one exact callback per environment. Avoid wildcard paths. The callback should receive a code, then immediately send the browser to a clean URL so the code is not retained in history, logs, or screenshots.
- Bind the browser transaction.
Generate high-entropy state and a nonce, store their hashes with the transaction, and expire that record in a few minutes. Compare values in constant time. A missing or reused value is a failed login, not a prompt to retry with weaker checks.
- Prefer code exchange on the server.
The authorization code is short-lived and single-use. Exchange it from the server with the registered redirect URI and PKCE verifier. Never place an access token in a query string, fragment, or application log.
- Validate identity claims explicitly.
Check issuer, audience, signature, expiry, and the tenant claim you use for authorization. Email is an identifier, not proof that two tenants are the same person. Decide how to handle a changed email before production; silent relinking is a security incident waiting to happen.
- Issue a local session with a narrow lifetime.
Keep provider tokens out of the browser unless the product truly needs them. Rotate the local session identifier after the callback, set Secure and HttpOnly, and choose SameSite based on the actual cross-site flow. Revoke local sessions when an administrator disables the account.
- Instrument the rejection path.
Log a correlation ID, tenant, issuer, and rejection reason category, never raw codes or tokens. Count discovery misses, state mismatches, nonce failures, and code-exchange failures separately. During a directory cutover, those counters tell you whether the problem is routing, consent, clock skew, or a policy mismatch.
Here is the critical path in deliberately plain Python-like pseudocode. The functions stand for your standards-compliant OAuth/OIDC library; the ordering is the important part.
def callback(request):
txn = store.pop(request.cookies.get("oauth_txn"))
if not txn or expired(txn):
return reject("transaction_expired")
if not constant_time_equal(request.query["state"], txn.state):
return reject("state_mismatch")
tokens = oauth.exchange_code(
code=request.query["code"],
redirect_uri=txn.redirect_uri,
code_verifier=txn.pkce_verifier,
)
claims = oidc.validate_id_token(
tokens.id_token,
issuer=txn.issuer,
audience=client_id,
nonce=txn.nonce,
)
account = accounts.resolve(tenant=txn.tenant, subject=claims["sub"])
session_id = sessions.rotate(account.id)
return redirect("/", set_cookie=session_cookie(session_id))
3. How do you test the ugly edges before enterprise rollout?
Test the state machine, not only a successful redirect. Replay the same code. Swap the issuer while keeping the subject. Expire the transaction halfway through consent. Send a callback with two code parameters, a missing nonce, or a redirect URI from staging. Each case should produce a bounded error and a useful correlation ID.
I once treated a delivery timeout as a provider outage because our logs joined events by user email. The real issue was a retry that created two transaction records; the later callback matched the wrong one. OAuth tests need the same discipline: join by transaction ID, make duplicate callbacks harmless, and preserve the original issuer, redirect URI, PKCE verifier, nonce, and creation timestamp together. When an enterprise admin changes a domain, the old transaction must still resolve against the issuer that started it; otherwise a perfectly valid callback can be attached to the wrong tenant. This is the kind of bug that produces a clean HTTP 302 and a dangerous account link, so I also assert the final subject and tenant pair in the test fixture.
Use contract tests against a local authorization server and browser tests for cookie behavior. Add clock-skew tests around the token exp claim. Your mileage may vary on enterprise policy, especially when a tenant requires a maximum authentication age or step-up MFA; make those requirements configuration, not hidden branches.
4. Rejected option: letting the identity provider own the application callback
Some teams put a third-party gateway between the browser and the application, then let that gateway decide account linking and session lifetime. It can be valid when a company already operates a central identity perimeter and every application accepts its signed assertion.
I would reject it for a developer tool with mixed tenants. You lose a direct view of transaction state, debugging crosses two logging systems, and a gateway policy change can alter application sessions without an application deploy. Keep the gateway only when its ownership, incident process, and claim contract are written down and tested end to end.
5. A decision rule that survives changing providers
Choose the architecture that keeps three facts locally verifiable: which tenant initiated the flow, which issuer authenticated the subject, and which callback created the session. Provider discovery can change; the audit record should not.
For a server-rendered product, a BFF with an authorization-code exchange is usually the cleanest fit. For a native app or a browser-only client, PKCE is the minimum baseline and the session boundary moves into the platform's secure storage. Stick with a centralized gateway when regulatory ownership requires it, and accept the additional operational coupling explicitly.
Security review should end with a runbook: rotate client credentials, disable a tenant, investigate a nonce mismatch, and recover from an issuer migration. If the team cannot answer those in under an hour, the design is not finished, regardless of how polished the login screen looks.
Top comments (0)