Home › Security › OAuth Redirect URI Mismatch — Fix Exact-Match Errors
Intermediate 5 min · September 23, 2026

OAuth Redirect URI Mismatch — Fix Exact-Match Errors

OAuth redirect_uri mismatch means the provider rejected your login request.

N
Naren Founder & Principal Engineer

20+ years shipping production backend systems. Notes here come from systems that actually shipped.

Follow
✓ Production
production tested
September 27, 2026
last updated
2,085
articles · all by Naren
Before you start⏱ 18 min
  • ✓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
 ● Production Incident 🔎 Debug Guide
⚡Quick Answer
  • 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
✦ Definition~90s read
What is OAuth 2.0 Redirect URI Mismatch Error?

A redirect URI (reply URL) is the address your application declares as the destination for OAuth 2.0 authorization responses. When your app starts a login, it sends the user to the provider's authorization endpoint with three critical parameters: client_id (who's asking), redirect_uri (where to send the answer), and response_type=code (what's being asked for).

★
Think of OAuth login like a hotel desk handing a key card to a bellhop with one strict order: deliver it to room 304.

After the user approves, the provider redirects back to that URI with an authorization code your backend exchanges for tokens.

The mismatch error fires when the redirect_uri in the request doesn't exactly equal a URI pre-registered for that client ID. This check is defined by OAuth security guidance because the redirect is the most attacked step in the flow. If an attacker can influence where codes go — through an open redirector, a wildcard registration, or a subdomain takeover — they can steal authorization codes and trade them for access tokens.

Exact matching closes that hole by binding code delivery to addresses you've explicitly approved.

Most providers do a plain string comparison, not a semantic URL equivalence check. That means http://localhost:3000/callback and http://localhost:3000/callback/ are different URIs. So are ports 3000 and 3001, uppercase and lowercase paths on many providers, and URIs with versus without query strings. Developers who think of these as the same address keep getting rejected.

PKCE (Proof Key for Code Exchange) is the companion control. Your app generates a random code_verifier, sends its hash as code_challenge with the login request, and presents the original verifier when redeeming the code. Even if someone intercepts the code at the redirect step, they can't exchange it without the verifier. PKCE doesn't fix mismatches, yet it limits the damage meanwhile.

Plain-English First

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.

auth/redirect_validation.pyPYTHON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
ALLOWED_REDIRECT_URIS = {
    "https://app.example.com/auth/callback",
    "https://staging.example.com/auth/callback",
    "http://localhost:3000/auth/callback",
}

def resolve_redirect_uri(requested: str) -> str:
    # Exact match only: no startswith, no regex, no normalization.
    if requested not in ALLOWED_REDIRECT_URIS:
        raise ValueError("Unregistered redirect_uri rejected")
    return requested

def build_authorize_url(client_id: str, redirect_uri: str) -> str:
    safe_target = resolve_redirect_uri(redirect_uri)
    params = urlencode({
        "client_id": client_id,
        "redirect_uri": safe_target,
        "response_type": "code",
        "scope": "openid profile email",
    })
    return f"https://provider.example.com/authorize?{params}"
📊 Production Insight
A team once validated redirects with startswith against their domain, thinking it was friendlier. An attacker subdomain passed the check and harvested test codes for a week. Exact set membership is the only safe comparison.
🎯 Key Takeaway
Providers compare redirect URIs as literal strings, so every character counts. Keep registrations in version control and enforce exact set membership on your side too.

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.

src/authConfig.jsJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
const REDIRECT_URIS = {
  development: 'http://localhost:3000/auth/callback',
  staging: 'https://staging.example.com/auth/callback',
  production: 'https://app.example.com/auth/callback',
};

export function getAuthConfig() {
  const env = process.env.APP_ENV || 'development';
  const redirectUri = REDIRECT_URIS[env];
  if (!redirectUri) {
    throw new Error(`No redirect URI registered for environment: ${env}`);
  }
  return {
    clientId: process.env.OAUTH_CLIENT_ID,
    redirectUri, // sent verbatim; never concatenated or trimmed
    scope: 'openid profile email',
  };
}
Try it live
📊 Production Insight
One team let Vite auto-pick ports when 3000 was busy. Every collision produced a mismatch that looked like a provider outage. Pinning the port in vite.config.js ended a month of phantom login bugs.
🎯 Key Takeaway
Pin one localhost port per project, register it once, and fail fast if the port is taken. Never let tooling silently change the port your registration depends on.

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.

BASH
1
2
3
4
5
6
7
8
9
10
# Fail CI when the app's redirect_uri drifts from the registered list
SENT_URI=$(node -e "import('./dist/authConfig.js').then(m => console.log(m.getAuthConfig().redirectUri))")

if ! grep -Fxq "$SENT_URI" ./auth/registered-redirect-uris.txt; then
  echo "MISMATCH: sent URI is not in the registered list: $SENT_URI"
  echo "Registered URIs:"
  cat ./auth/registered-redirect-uris.txt
  exit 1
fi
echo "OK: $SENT_URI is registered verbatim"
📊 Production Insight
A marketing script appended ?utm_source to every URL including the OAuth authorize link. Logins broke for two days because the registered URI had no query string. Dynamic data belongs in state, never in redirect_uri.
🎯 Key Takeaway
Standardize one slash form, lowercase paths, and keep redirect_uri free of query strings. Assert the sent URI against the registered list in CI.

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.

auth/pkce.pyPYTHON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import base64
import hashlib
import secrets

def new_code_verifier() -> str:
    # 64 random bytes -> 86 URL-safe chars, within the 43-128 range.
    return secrets.token_urlsafe(64)

def code_challenge_s256(verifier: str) -> str:
    digest = hashlib.sha256(verifier.encode("ascii")).digest()
    return base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii")

def authorize_params(client_id: str, redirect_uri: str, verifier: str) -> dict:
    return {
        "client_id": client_id,
        "redirect_uri": redirect_uri,  # must match registration exactly
        "response_type": "code",
        "code_challenge": code_challenge_s256(verifier),
        "code_challenge_method": "S256",  # never 'plain' in production
    }
📊 Production Insight
A team shipped PKCE with method plain to dodge a hashing bug, sending the verifier equivalent in cleartext through the browser. They'd locked the door and taped the key to it. Always use S256.
🎯 Key Takeaway
Generate a random verifier per login, send only its S256 hash, and redeem with the original. Use PKCE on every client type, not just mobile.

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.

⚠ Never register wildcard redirect URIs
A wildcard like https://.example.com/ lets any subdomain — including expired or compromised ones — receive authorization codes. Register complete literal URIs only, one per environment per platform.
📊 Production Insight
An expired docs subdomain fell under a wildcard registration and was re-registered by a stranger. Test codes flowed there for 9 days before anyone noticed. Literal-only registrations would have made the takeover useless.
🎯 Key Takeaway
One literal URI per environment per platform, separate client IDs per app type, and quarterly cleanup of stale entries. Manage the list as code where possible.

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.

📊 Production Insight
During one outage, two engineers edited the dashboard and the code at the same time. Both changes were individually correct and mutually incompatible, extending a 10-minute fix to 50 minutes. One side at a time, always.
🎯 Key Takeaway
In an outage, change one side only and diff first. Afterward, pin the URI in env config, assert it in CI, and fix whatever workflow keeps producing drift.
● Production incidentPOST-MORTEMseverity: high

A One-Path Deploy Change Logged Out 12,000 Users for 38 Minutes

Symptom
At 4:02 PM on a Friday, the login success rate fell from 99.1% to 0% within 4 minutes of a frontend deploy. Roughly 12,000 login attempts failed over the next 38 minutes. Users saw the provider's consent screen, clicked approve, then landed on a provider-hosted error page saying the redirect URI didn't match. No errors appeared in the app's own logs because the failure happened at the provider before any code returned. Support tickets spiked to 340 in half an hour, and the on-call engineer initially paged the identity provider's status page, which showed all systems green.
Assumption
The team assumed the provider was having an outage because nothing in their code had touched authentication — the deploy notes mentioned only a router refactor. They also assumed their staging tests covered login, but the staging client ID used a wildcard-friendly test provider config that accepted any localhost path, so the path rename passed staging cleanly. Nobody compared the callback path in the shipped bundle against the dashboard registration.
Root cause
The router refactor renamed the callback route from /callback to /auth/callback to group auth pages together. The built app therefore sent redirect_uri=https://app.example.com/auth/callback while the dashboard still registered https://app.example.com/callback. The provider's exact-match check rejected all 12,000 attempts. The mismatch was invisible in code review because the redirect URI was constructed from a route constant, and invisible in staging because the staging provider tenant had a looser test configuration that didn't enforce the same registration.
Fix
Three changes shipped that evening. First, the dashboard registration was updated to the new path, which restored logins within 6 minutes of the change propagating. Second, the redirect URI was moved from a derived route constant to an explicit environment variable (AUTH_CALLBACK_URL) that fails the build when unset, so route refactors can't silently move it. Third, a CI check was added that extracts the redirect_uri from a test authorization request and asserts it appears verbatim in the provider's registered list via the provider's management API — a mismatch now fails the pipeline instead of reaching production.
Key lesson
  • 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.
Production debug guideFive checks that resolve nearly every mismatch, ordered from fastest to deepest.5 entries
Symptom · 01
Provider shows redirect_uri_mismatch immediately after user approval
→
Fix
Capture the exact redirect_uri your app sent: open devtools, watch the network request to the /authorize endpoint, and copy the redirect_uri query parameter verbatim. Paste it next to the dashboard registration in a text diff — don't eyeball it. In 70% of cases you'll spot a trailing slash, wrong port, or http-vs-https difference within a minute. Fix whichever side is wrong and retry.
Symptom · 02
URI looks identical but the provider still rejects it
→
Fix
Check for invisible differences: URL-encoded characters (%2F vs /), uppercase path segments, a query string your framework appends (like ?code_verifier state), or a fragment. Run the sent URI through decodeURIComponent and compare again. Also confirm you're editing the registration for the right client ID — many teams have separate dev, staging, and prod clients and update the wrong one.
Symptom · 03
Mismatch only happens in one environment (staging or prod, but not local)
→
Fix
Dump the runtime redirect URI in that environment: log it at startup or expose it on a debug endpoint, then compare against that environment's dashboard entry. The usual culprits are hardcoded localhost ports in shared config, a reverse proxy changing the path prefix, or an env var that wasn't set so the app fell back to a default. Make the redirect URI an explicit required env var per environment.
Symptom · 04
Mobile app or desktop app gets rejected while web works
→
Fix
Native apps often use custom schemes (myapp:/callback) or loopback addresses that need separate registration entries — most providers won't accept a web HTTPS URI from a mobile client ID. Register a dedicated client per platform with its own redirect URIs, and prefer claimed HTTPS app links over custom schemes where the OS supports them, since custom schemes can be intercepted by other installed apps.
Symptom · 05
Mismatch is fixed but logins still fail at code exchange with invalid_grant
→
Fix
You've fixed delivery but broken redemption: the redirect_uri sent to the token endpoint must be byte-identical to the one sent to the authorize endpoint. Log both values and diff them. SDKs sometimes normalize one and not the other. While you're here, confirm PKCE is wired up — send code_challenge with the authorize request and the matching code_verifier at redemption — so intercepted codes are useless.
Redirect URI Failures at a Glance
Root CauseHow to ConfirmFixPrevention
Unregistered port or path in the requestDiff the sent redirect_uri against the dashboard list character by characterRegister the exact URI or correct the app to send the registered oneFixed ports per project plus a CI assertion on the sent URI
Trailing slash or case differenceDecode the sent URI and compare slash and casing explicitlyStandardize one canonical form on both sidesLowercase callback paths and lint for trailing-slash consistency
Wrong client ID for the environmentCheck which client ID the failing app sends versus which dashboard entry you editedPoint each environment at its own client IDRequired per-environment env vars; never share client IDs across envs
Code intercepted because PKCE is missingInspect the authorize request for an absent code_challenge parameterAdd S256 PKCE to every authorization requestSDK defaults with PKCE on; fail code review when it's disabled
⚙ Quick Reference
4 commands from this guide
FileCommand / CodePurpose
authredirect_validation.pyALLOWED_REDIRECT_URIS = {Exact Match Means Exact
srcauthConfig.jsconst 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
authpkce.pydef new_code_verifier() -> str:PKCE

Key takeaways

1
Redirect URIs are compared as literal strings
scheme, host, port, path, and slash must all match.
2
Pin one localhost port per project and register it once; never let tooling silently change ports.
3
Keep redirect_uri static and clean; carry dynamic data in the state parameter instead.
4
Use S256 PKCE on every authorization request so intercepted codes can't be redeemed.
5
Register literal URIs only
no wildcards — with separate client IDs per platform.
6
Assert the sent URI against the registered list in CI so drift fails builds, not logins.

Common mistakes to avoid

5 patterns
×

Building the redirect URI by concatenating host headers and route paths

Symptom
Logins work locally but fail behind a proxy, or break silently whenever a route is renamed, because the sent URI drifts from the registration.
Fix
Use one explicit literal URI per environment from required config. Fail the build when it's unset instead of guessing from the request.
×

Sharing one client ID across web, iOS, and Android

Symptom
Mobile-specific redirect schemes get accepted in contexts they shouldn't, and rotating one platform's credentials disrupts the others.
Fix
Create a separate client per platform, each with only the redirect URIs that platform needs.
×

Registering wildcard or overly broad redirect patterns

Symptom
Any current or future subdomain can receive authorization codes, turning every forgotten microsite into a code-theft risk.
Fix
Replace wildcards with complete literal URIs and audit the list quarterly for stale environments.
×

Putting dynamic data in redirect_uri instead of the state parameter

Symptom
Tracking parameters or return-to paths appended to the URI break exact matching on strict providers.
Fix
Send a clean static redirect_uri and carry dynamic data in state, validating it on return.
×

Skipping PKCE because the client is 'confidential enough'

Symptom
An intercepted authorization code can be exchanged by anyone who holds it, since nothing binds redemption to the original requester.
Fix
Send an S256 code_challenge with every authorization request and redeem with the matching verifier.
INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

Q01JUNIOR
Why does an OAuth provider reject a redirect URI that differs by only a ...
Q02SENIOR
Your SPA gets redirect_uri_mismatch on staging but not locally. Walk thr...
Q03SENIOR
What does PKCE protect against, and what doesn't it fix?
Q04SENIOR
Why is a wildcard redirect URI registration dangerous even on domains yo...
Q05SENIOR
How would you prevent redirect URI drift between code and dashboard in a...
Q01 of 05JUNIOR

Why does an OAuth provider reject a redirect URI that differs by only a trailing slash?

ANSWER
Because it compares redirect URIs as literal strings, not as equivalent web addresses. The redirect is where authorization codes are delivered, so approximate matching would let near-miss attacker addresses receive codes. Exact matching binds code delivery to pre-approved addresses only.
FAQ · 6 QUESTIONS

Frequently Asked Questions

01
Can I register multiple redirect URIs for one client ID?
02
Should localhost redirect URIs use http or https?
03
Do I need PKCE if my backend is a confidential client with a secret?
04
Why must the token request repeat the same redirect_uri?
05
Are custom URL schemes safe for mobile redirects?
06
How do preview environments fit with exact-match registration?
N
Naren Founder & Principal Engineer

20+ years shipping production backend systems. Notes here come from systems that actually shipped.

Follow
✓ Verified
production tested
September 27, 2026
last updated
2,085
articles · all by Naren
🔥

That's Auth. Mark it forged?

5 min read · try the examples if you haven't

←
Previous
Password Hashing: Why MD5 and SHA-256 Both Fail
4 / 5 · Auth
Next
Insecure Direct Object Reference (IDOR)
→