OAuth Redirect URI Mismatch — Fix Exact-Match Errors
OAuth redirect_uri mismatch means the provider rejected your login request.
20+ years shipping production backend systems. Notes here come from systems that actually shipped.
- ✓A working OAuth 2.0 login flow you've built or integrated
- ✓Access to your identity provider's application dashboard
- ✓Basic familiarity with HTTP redirects and query parameters
- A redirect URI mismatch means the redirect_uri you sent doesn't exactly match a URI registered for your client ID
- Match every character: scheme, host, port, path, and trailing slash all count, so localhost:3000 and localhost:3001 are different apps
- Register each environment's URI separately in the provider dashboard and don't share one client ID across web and mobile
- Add PKCE (code_challenge) to every authorization request from SPAs and mobile apps so a stolen code can't be replayed
- Log the full authorize URL in staging and diff it against the dashboard registration to catch drift before release
Think of OAuth login like a hotel desk handing a key card to a bellhop with one strict order: deliver it to room 304. The redirect URI is that room number. Your app tells the identity provider exactly which address should receive the login code, and the provider checks it letter-for-letter against its registered list. If anything differs, it refuses to hand over the code. That refusal is deliberate: without it, login codes could be delivered to an address an attacker controls.
You've wired up the login button, the provider's consent screen appears, the user clicks approve — and instead of landing back in your app, they stare at an error page: redirect_uri_mismatch. No stack trace in your code. No failing test. Just a rejected handshake between your app and the identity provider.
This error is the single most common OAuth integration failure, and it almost never means your login logic is wrong. It means the redirect_uri parameter in your authorization request doesn't exactly match one of the URIs you registered for your client ID. Providers compare these strings with brutal literalness: a different port, a missing trailing slash, or http where you registered https is enough to kill the flow.
That strictness exists for a reason you'll appreciate once you see the alternative. The redirect URI is where the provider delivers the authorization code — a credential worth stealing. If providers accepted approximate matches, an attacker could register a lookalike address and harvest codes meant for your users.
By the end of this guide you'll know how exact-match comparison really works, why localhost development keeps tripping over ports, how trailing slashes and query strings silently break logins, and where PKCE fits in. You'll also leave with a debugging routine that resolves most mismatches in minutes instead of tickets.
Exact Match Means Exact: How Providers Compare Redirect URIs
When the provider receives your authorization request, it takes the redirect_uri parameter and compares it against the registered list with essentially a string equality check. Scheme, host, port, path, and query string all participate. There is no fuzzy logic, no redirect following, and no guessing what you meant. If the strings differ by a single character, the request is rejected — and rejected loudly, because silently delivering a code to a near-miss address is exactly how code theft happens.
This strictness surprises developers who think in terms of web addresses rather than security bindings. To a human, http://localhost:3000/callback and http://localhost:3000/callback/ are the same page. To the provider, they're different delivery instructions, and only registered instructions are honored. The same goes for ports: your React dev server on 3000 and your Vue prototype on 8080 are different apps as far as matching is concerned, even on the same laptop.
The defensive way to live with this is to treat registration as code. Keep the canonical list of redirect URIs in version control, update it through the same review process as any auth change, and validate it in CI by fetching the provider's registered list through its management API. The snippet below shows the server-side habit that matters most: never trust a redirect_uri arriving in a request — accept only values from your own allowlist with exact matching, and reject everything else before any further processing happens.
Localhost Ports and Loopback Redirects in Development
Local development is where most mismatch pain lives, because every developer's machine is a slightly different environment. OAuth security guidance carves out a narrow exception for loopback addresses (localhost and 127.0.0.1), letting native apps use them without pre-registering every ephemeral port. But browser-based flows on localhost still need registered URIs, and the port is part of the identity — change from 3000 to 3001 and you're a stranger again.
The practical fallout: two developers running the same app on different ports need two registrations. A dev server that picks a random free port on each start will never match anything. And localhost versus 127.0.0.1 are different hosts to the matcher even though they route to the same machine, so pick one spelling and standardize it across the team.
Set a fixed port per project in your dev tooling and document it. Register http://localhost:<port>/auth/callback for each project once, and make the dev server fail fast if the port is taken instead of silently drifting to the next free one. The snippet below shows a config builder that selects the redirect URI from the environment with no string surgery at runtime — the URI is a complete literal per environment, which is exactly what exact-match wants from you.
Trailing Slashes, Case, and Query Strings That Break Logins
The mismatch errors that survive the obvious checks almost always come down to three quiet string differences. First, the trailing slash: /callback and /callback/ differ, and routers commonly redirect one to the other — but the provider compares the pre-redirect string you sent, so the redirect never gets a chance to help. Decide on one canonical form and use it everywhere.
Second, case sensitivity. The host part of a URL is case-insensitive, but the path often isn't, and providers differ in how they treat it. App.Example.com/Auth/Callback might match on one provider and fail on another. Lowercase your callback paths and keep them lowercase in both code and dashboard.
Third, query strings. Some frameworks append tracking or state parameters to the redirect URI before sending the authorization request. If those parameters aren't part of the registered URI, matching fails on strict providers. Keep dynamic data in the state parameter — that's what it's for — and send a clean, static redirect_uri. The check below belongs in your staging pipeline: it performs the authorization request, extracts the redirect_uri actually sent, and fails the build when it isn't verbatim in the registered set.
PKCE: Proof That the App That Started Login Finishes It
PKCE exists for the attack that exact matching can't stop: interception of the authorization code after legitimate delivery. On mobile devices and in browser histories, codes can leak through logs, referer headers, or malicious apps watching custom schemes. Without PKCE, anyone holding the code can trade it for tokens. With PKCE, the code alone is worthless because redemption requires the original random verifier.
The mechanics are simple. Before redirecting to the provider, your app generates a random code_verifier (43-128 characters), hashes it with SHA-256, and sends the hash as code_challenge with the authorization request. When exchanging the code for tokens, it presents the original verifier. The provider hashes it again and compares. An interceptor who only saw the code and the challenge hash can't reverse the hash to recover the verifier.
Two points teams get wrong. First, PKCE is mandatory for public clients (SPAs, mobile apps) and recommended for confidential clients too under current OAuth guidance — use it everywhere rather than debating client types. Second, PKCE doesn't fix redirect mismatches and isn't a substitute for exact matching; it's the second lock on the same door. Get the URI right so codes reach your app, and use PKCE so leaked codes can't be spent.
Registering Redirect URIs Without Opening Holes
Registration is a security decision disguised as admin clicking. Every URI you add is an address the provider will deliver credentials to, so each entry expands your attack surface. The most dangerous shortcut is the wildcard: some providers or legacy setups allow patterns like https://.example.com/, which hand code-delivery rights to every current and future subdomain — including the forgotten marketing microsite someone lets expire next year.
Register the minimum set: one URI per environment per platform, each a complete literal. Give web, iOS, and Android separate client IDs so a mobile-specific redirect scheme can never be used to attack the web flow. For mobile, prefer claimed HTTPS app links (which the OS binds to your app) over custom URL schemes (which any installed app can claim first on some platforms). Review the list quarterly and delete entries for decommissioned environments — stale staging URIs pointing at torn-down infrastructure are takeover bait.
Where your provider offers a management API, manage registrations as code: store the intended list in your repo and reconcile it on deploy. That turns dashboard drift into a visible diff and makes the Friday-deploy incident class structurally impossible. Pair this with a short-lived review rule: any pull request touching auth config needs a second reviewer who checks the dashboard side too.
Fixing the Mismatch in Production Without Guessing
When logins are failing right now, resist the urge to edit both sides at once. Pick the source of truth — the dashboard registration — and change exactly one side. Editing code and dashboard simultaneously creates a moving target where neither matches and nobody knows which change helped. Capture the failing redirect_uri from a real authorize request first, diff it against the registration, and make the smallest edit that closes the gap.
After the immediate fix, harden the path so the class can't recur. Move the redirect URI into an explicit required environment variable rather than deriving it from routes or host headers. Add the CI assertion from the earlier section so drift fails builds. And log (never store) the redirect_uri on authorization failures in staging so the next mismatch announces itself instead of hiding.
Finally, treat every mismatch ticket as a signal about your process, not just a typo. If mismatches recur, the registration workflow is broken: maybe frontend deploys don't include a dashboard checklist, or preview environments generate URLs nobody registers. Fix the workflow — a per-preview registration script or a fixed set of preview URLs — and the typos stop mattering.
A One-Path Deploy Change Logged Out 12,000 Users for 38 Minutes
- Treat the registered redirect URI as a contract, not config: any code change that alters the callback path must update the provider dashboard in the same release, reviewed like a migration.
- Staging must enforce the same exact-match rules as production. A lenient test tenant hides the exact class of bug that takes down real logins.
- Build a CI assertion that diffs the URI your app sends against the registered list. Twelve thousand failed logins is an expensive way to learn a string changed.
| File | Command / Code | Purpose |
|---|---|---|
| auth | ALLOWED_REDIRECT_URIS = { | Exact Match Means Exact |
| src | const REDIRECT_URIS = { | Localhost Ports and Loopback Redirects in Development |
| SENT_URI=$(node -e "import('./dist/authConfig.js').then(m => console.log(m.getAu... | Trailing Slashes, Case, and Query Strings That Break Logins | |
| auth | def new_code_verifier() -> str: | PKCE |
Key takeaways
Common mistakes to avoid
5 patternsBuilding the redirect URI by concatenating host headers and route paths
Sharing one client ID across web, iOS, and Android
Registering wildcard or overly broad redirect patterns
Putting dynamic data in redirect_uri instead of the state parameter
Skipping PKCE because the client is 'confidential enough'
Interview Questions on This Topic
Why does an OAuth provider reject a redirect URI that differs by only a trailing slash?
Frequently Asked Questions
20+ years shipping production backend systems. Notes here come from systems that actually shipped.
That's Auth. Mark it forged?
5 min read · try the examples if you haven't