redirect_uri_mismatch and the Rest of OAuth Debugging
OAuth errors are unhelpful by design, because detailed errors would help attackers too. Here is how to work out what is actually wrong.
The short answer
Error 400: redirect_uri_mismatch
The redirect_uri you sent does not exactly match one registered with the provider. Exactly means character for character:
httpandhttpsdifferexample.comandwww.example.comdiffer- A trailing slash differs
- Port 3000 and no port differ
- Case in the path can matter
Print what you are actually sending rather than what you think you are sending:
console.log(JSON.stringify(redirectUri));
The JSON.stringify matters. It reveals trailing whitespace and invisible characters that a bare log hides.
Why OAuth errors are so vague
Deliberate. Detailed errors at the authorisation endpoint would help an attacker enumerate valid clients and redirect URIs.
So you get invalid_request for a dozen different problems, and the useful detail is either absent or in a provider specific field. This is the opposite of what I would normally argue for in error design, and here the reasoning is sound.
The consequence is that you have to diagnose by construction: check each thing that could be wrong rather than reading the error.
redirect_uri, in detail
The most common failure and worth being systematic about.
Print it, do not assume it. Most redirect URI bugs are a value being constructed differently than expected:
const redirectUri = `${process.env.APP_URL}/auth/callback`;
console.log(JSON.stringify({ redirectUri, appUrl: process.env.APP_URL }));
If APP_URL has a trailing slash, you get https://example.com//auth/callback with a double slash, which will not match.
It must match at both steps. The redirect_uri in the authorisation request and the one in the token exchange must be identical. Providers check this, and constructing it in two places is how they end up different.
Build it once:
const REDIRECT_URI = new URL("/auth/callback", process.env.APP_URL).toString();
new URL normalises the double slash problem and throws if the base is malformed, which is better than silently producing something wrong.
Behind a proxy, your app may not know its own URL. If your service sees http://localhost:8080 while users see https://app.example.com, any URL you construct from request headers is wrong.
app.set("trust proxy", true); // Express: honour X-Forwarded-Proto and Host
And prefer an explicit configured URL over deriving it from the request. Deriving it is also a security consideration, because Host is attacker controlled unless your proxy overwrites it.
Localhost is special. Many providers allow http://localhost with any port for development, while requiring HTTPS elsewhere. 127.0.0.1 and localhost are frequently treated as different strings even though they resolve identically.
The other common errors
invalid_client
Wrong client id, wrong secret, or the secret is being sent in a way the provider does not accept.
Providers differ on whether the client credentials go in an Authorization: Basic header or in the form body. Some accept either, some require one.
# basic auth
curl -X POST https://provider.com/oauth/token \
-u "$CLIENT_ID:$CLIENT_SECRET" \
-d "grant_type=authorization_code&code=$CODE&redirect_uri=$URI"
# body
curl -X POST https://provider.com/oauth/token \
-d "client_id=$CLIENT_ID&client_secret=$CLIENT_SECRET&grant_type=authorization_code&code=$CODE&redirect_uri=$URI"
Try both. The documentation is often ambiguous.
Also check for a secret with a trailing newline, which happens when it is read from a file:
const secret = readFileSync(path, "utf8").trim();
invalid_grant
The most ambiguous error in OAuth. It means one of:
- The authorisation code was already used. Codes are single use, and a double submit or a React effect firing twice will consume it.
- The code expired, typically after 30 to 600 seconds.
- The
redirect_uriin the token request does not match the one in the authorisation request. - The refresh token was revoked or rotated.
The double use case deserves attention because it is easy to cause accidentally. In React, an effect running twice under Strict Mode will exchange the code twice, and the second attempt fails with invalid_grant while the first succeeded. The user sees an error on a successful login.
Handle the callback server side where possible. If it must be client side, guard against re-execution and treat a second attempt as already-succeeded rather than as an error.
invalid_scope
The scope string format differs by provider. Space separated is the specification. Some providers use commas. Some require URL encoding of the whole parameter and some do not.
scope=read:user user:email # GitHub, space separated
scope=openid%20profile%20email # encoded spaces
Also: the scope must be one your application is approved for. Many providers require review before granting sensitive scopes, and requesting one you do not have produces this error rather than something clearer.
state mismatch
You should be validating state on the callback to prevent CSRF. If it fails legitimately:
The session was lost between the redirect and the callback. Common with cookies that have SameSite=Strict, because the callback arrives as a cross site navigation from the provider and the cookie is not sent.
Use SameSite=Lax for the session cookie holding the OAuth state. Lax sends cookies on top level navigations, which is what an OAuth callback is.
Load balanced servers with no shared session store. The authorisation request hit one instance, the callback hit another, and the state is in the first one's memory. Use a shared store.
Debugging technique
Decode the JWT, if you get one. Do not trust it, just read it:
echo "$TOKEN" | cut -d. -f2 | base64 -d 2>/dev/null | jq
Check aud matches your client id, iss matches the provider, and exp is in the future. An aud mismatch means you are validating a token issued for a different application, which is a real security consideration and not just a bug.
Clock skew causes exp and nbf validation to fail on a server whose time is wrong. If tokens are rejected as expired immediately after issue, check NTP. Most libraries allow a small leeway, typically 30 to 60 seconds, and it is worth configuring rather than leaving at zero.
Log the full authorisation URL you construct, and open it manually in a browser. Half the time the problem is visible in the URL.
Use the provider's playground if they have one. Google, GitHub, and Shopify all provide a tool that constructs a valid request, and comparing yours against theirs parameter by parameter finds the difference quickly.
Check the provider's application settings page. Registered redirect URIs, allowed grant types, and whether the app is in development or production mode. A surprising number of problems are a setting nobody looked at.
PKCE, which you should be using
For any public client, meaning a mobile app or a single page application, PKCE is now expected rather than optional.
const verifier = base64url(crypto.randomBytes(32));
const challenge = base64url(crypto.createHash("sha256").update(verifier).digest());
// authorisation request
// ...&code_challenge=${challenge}&code_challenge_method=S256
// token request
// ...&code_verifier=${verifier}
Two common mistakes: using base64 instead of base64url, which produces characters that break in a URL, and hashing the wrong thing. The challenge is the hash of the verifier, and the verifier is what you send at token exchange.
If PKCE fails with invalid_grant, verify your base64url encoding by round tripping it before assuming the flow is wrong.
A checklist
- Print the
redirect_uriwithJSON.stringify - Confirm it is byte identical in both requests and in the provider's settings
- Check
trust proxyif you are behind a load balancer - Confirm client credentials are sent in the form the provider expects
- Check the secret has no trailing whitespace
- Confirm the code is only exchanged once
- Use
SameSite=Laxon the session cookie - Verify server time is correct
- Decode the token and check
aud,iss,exp