2025-11-30 | PreviewProof Team
OAuth Callback URLs and Ephemeral Previews: Solving the Dynamic URL Problem
If your preview environments work for static pages and break the moment a reviewer hits “Sign in with Google,” you’re in the company of every other team that’s tried to build this. OAuth is the single most common reason preview environments stop being useful halfway through implementation. The protocol assumes a registered callback URL. Previews produce a new URL every commit. The two are designed against each other.
Four patterns work in production. The right one depends on your identity provider and how much complexity you’re willing to absorb.
Pattern 1: Wildcard Callback Registration
The simplest answer when your IdP supports it: register a wildcard pattern instead of an exact URL.
https://*.preview.acme.dev/auth/callbackThe application reads the actual hostname at runtime and presents that as the redirect_uri in the authorization request. The IdP validates the wildcard, the redirect succeeds.
Who supports it: Auth0 and Okta support wildcards in non-production tenants only, and only in the subdomain — not the path. https://*.preview.acme.dev/callback works; https://preview.acme.dev/*/callback doesn’t. Google Cloud OAuth, GitHub OAuth Apps, and AWS Cognito explicitly don’t support wildcards. Microsoft Entra doesn’t support wildcards but allows up to 256 registered redirect URIs per app — a different escape hatch.
When this works, it’s the right answer. When it doesn’t, move down the list.
Pattern 2: Single Static Callback with State-Encoded Redirection
You register one callback URL — a stable, non-preview URL like https://auth.acme.dev/callback. That endpoint completes the OAuth dance, then redirects to the actual preview based on a value encoded in the OAuth state parameter.
// at sign-in time, on the previewconst state = signedJwt({ preview: window.location.origin, // https://pr-482.preview.acme.dev nonce: crypto.randomUUID(),});
window.location = `https://auth.acme.dev/login?state=${state}`;// auth.acme.dev/callback handlerconst { code, state } = req.query;const { preview, nonce } = verifyJwt(state);
const tokens = await exchangeCodeForTokens(code);const sessionToken = signSession(tokens);
// hand the session back to the previewres.redirect(`${preview}/auth/finish?token=${sessionToken}`);This works with any IdP. The preview never needs to be registered. The cost is that you’re now operating an auth proxy, and you have to be careful: the state parameter must be signed (not just opaque), the redirect target must be validated against an allowlist of preview hostnames, and the session token handed back to the preview should be short-lived and bound to the nonce.
The pitfall here is sloppy state validation. If anyone can stuff an arbitrary URL into state, you’ve built an open redirector that helpfully exchanges OAuth codes on the way through. Validate the preview origin against a regex, a domain suffix, or a list of active preview hostnames pulled from your platform’s API.
Pattern 3: Dedicated Preview-Environment OAuth Apps
Run a separate OAuth app — different client ID, different secret — that exists only for previews. Pre-register a pool of generic callback URLs (pr-1.preview.acme.dev, pr-2.preview.acme.dev, up to your concurrency ceiling). Recycle them as PRs close.
This sounds tedious because it is. It also has the lowest moving-parts risk of any approach: no proxy, no state-juggling, just a static list. For organizations where any auth-adjacent code triggers heavy security review, “we registered 50 callback URLs once” is much easier to ship than “we built a redirect proxy.”
Microsoft Entra works particularly well here because of its 256-URL ceiling. Cognito works for smaller pools. For Google Cloud OAuth — every URL added by hand — this is a non-starter past a dozen entries.
Pattern 4: Proxy-Callback Pattern
The most flexible and the most work. A small dedicated service — typically running on a stable subdomain like oauth.acme.dev — handles the OAuth dance and forwards the result to the preview that asked for it.
The flow:
- Preview redirects user to
https://oauth.acme.dev/start?return_to=https://pr-482.preview.acme.dev/auth/finish - Proxy stores
return_toin a short-lived signed token, redirects to the IdP with its own (registered) callback URL - IdP redirects back to
https://oauth.acme.dev/callback - Proxy exchanges the code, mints a session, and redirects to the preview’s
/auth/finishwith the session token
This is functionally equivalent to Pattern 2 but factored as a separate service rather than a shared callback handler in your main app. It’s worth the extra service when you have multiple apps that all need to share the same OAuth identity, because each app then doesn’t have to implement its own state-encoded redirection.
It also pairs naturally with service-to-service auth in multi-service previews — the proxy becomes the trust anchor for issued sessions, and downstream services validate against it.
IdP-Specific Notes
Google Cloud OAuth is the strictest. No wildcards, no path patterns, exact URLs only. Patterns 2 or 4 are your only options for previews.
Auth0 supports comma-separated wildcard lists in dev/staging tenants. Use a separate Auth0 tenant for previews — never put preview wildcards on your production tenant.
Okta behaves like Auth0 for preview-class apps. Use the “OIDC - Web” application type with development enabled.
GitHub OAuth Apps have a single callback URL: Pattern 2 is the standard answer. GitHub Apps support multiple callbacks, but adding/removing them still requires API calls.
Microsoft Entra with 256 redirect URIs: Pattern 3 is genuinely viable, especially if you also use Entra for service-to-service auth.
AWS Cognito supports up to 100 callback URLs per user pool client. Pattern 3 works for small pools; Pattern 2 or 4 for larger ones.
Decision Matrix
| Situation | Pattern |
|---|---|
| Auth0 or Okta dev tenant, single app | 1 (wildcards) |
| Google or any IdP without wildcard support, single app | 2 (state-encoded) |
| Entra or Cognito with bounded preview concurrency | 3 (URL pool) |
| Multiple apps sharing identity in previews | 4 (proxy) |
The order is roughly “what’s the smallest amount of code that makes this work for your specific IdP.” Pattern 1 if you can. Pattern 2 if you can’t. Pattern 3 when the registered-list ceiling fits your scale. Pattern 4 when you have a fleet.
A common combination: production uses Pattern 1 against the production OAuth app; previews use Pattern 2 or 4 against a separate preview OAuth app. That keeps wildcard or proxy logic out of the production blast radius.
Testing OAuth in Previews
Whichever pattern you pick, the test is the same: a real browser, a real OAuth round trip, ending up logged in with a real session. Mocking OAuth tells you nothing about whether the dynamic URL plumbing is correct. Most teams discover their pattern is broken when a stakeholder hits a redirect_uri mismatch error. Catch it in CI by running a headless browser through the flow against an actual preview before the PR is reviewable.
If you’d rather not implement Pattern 2 or Pattern 4 yourself, PreviewProof handles preview-aware OAuth for the major IdPs out of the box — wildcards where they work, proxy callbacks where they don’t, with stakeholder-accessible login that doesn’t require your reviewers to have a GitHub account or a VPN. If you’re going to build it yourself, the patterns above are the ones we’d recommend regardless.