Skip to content

Fixing OAuth 2.0 Errors: A Developer's Guide to invalid_grant & More

A practical guide to fixing the most common OAuth 2.0 errors — invalid_grant, redirect_uri_mismatch, invalid_client — with real provider quirks and architectural fixes.

Sidharth Verma Sidharth Verma · · 14 min read
Fixing OAuth 2.0 Errors: A Developer's Guide to invalid_grant & More

OAuth 2.0 is the standard for secure API authorization. It is also, in practice, a source of some of the most confusing error messages in all of software engineering. If you are staring at {"error": "invalid_grant"} and have no idea whether it is your code, your config, or your provider's policy that is broken, this guide is for you.

We will break down the most common OAuth 2.0 errors, explain the real-world provider quirks that cause them, and show you the architectural patterns that prevent them from wrecking your integrations at scale.

Quick Triage: The Four Failure Classes

Before diving deep, here is the fast classification. Every common OAuth error maps to one of four failure classes:

  • invalid_grant — The authorization code or refresh token is invalid, expired, revoked, tied to another client, or no longer matches the original redirect URI.
  • redirect_uri_mismatch — The callback URI in your request does not exactly match what the provider has on file. Scheme, case, trailing slash — all matter.
  • invalid_client — The server could not authenticate your application. Bad secret, missing auth, wrong auth method.
  • unauthorized_client — The client is recognized but not allowed to use that grant type or flow.

Log the exact response body, the token endpoint, the grant_type, the client_id, the redirect_uri, and the environment. Most OAuth bugs survive because teams only log the error string and throw away the inputs that produced it.

The "Integration Hell" of OAuth 2.0 Debugging

The OAuth 2.0 spec reads like a clean, well-defined protocol. The reality is anything but. Every SaaS provider — Salesforce, Google, HubSpot, Microsoft, NetSuite — implements the spec with their own opinions, edge cases, and undocumented behaviors.

The cost of getting it wrong is significant. 69% of developers now spend more than 10 hours a week on API-related work. When API breaches do occur, they are dominated by identity and trust failures — broken authentication was the culprit in 52% of incidents in 2025. And 38% of organizations encountered an authentication issue in production APIs in the past year.

This is not a theoretical risk. When enterprise authentication breaks in production, it cascades into failed syncs, broken customer workflows, and support tickets that your team spends days diagnosing.

If you want the broader architectural view, read Beyond Bearer Tokens: Architecting Secure OAuth Lifecycles & CSRF Protection. This post stays narrower: fix the error in front of you, then stop it from showing up again.

invalid_grant: The Ambiguous Catch-All

invalid_grant means the authorization grant you provided — either an authorization code or a refresh token — is no longer valid. The problem is that this single error code covers at least five completely different failure modes, and providers almost never tell you which one it is.

{
  "error": "invalid_grant",
  "error_description": "Token has been expired or revoked."
}

This is why invalid_grant is such a miserable error string. It blends user action, provider policy, and client bugs into one label. Here are the real causes, ranked by how often they burn developers:

Expired or revoked refresh tokens

This is the number one cause. Each provider has its own revocation policies, and they are wildly different:

  • Google revokes refresh tokens if they have not been used for six months, if the user changed passwords and the token contains Gmail scopes, or if the user account has exceeded the maximum number of granted refresh tokens. That limit is currently 50 per client/user combination. If your application is still in testing status within the Google Cloud Console, all refresh tokens expire after just 7 days. That bug shows up constantly in internal QA and demo accounts.
  • Salesforce has its own set of traps. If 5 access tokens have already been issued for a user and you request a new auth, Salesforce will revoke the oldest of the 5 access/refresh token pairs. With the Spring 2024 release, Salesforce introduced an option to issue a new refresh_token with every token refresh flow — you need to store this new refresh token as the old one automatically gets revoked. That is an easy way to burn yourself if the same person keeps reconnecting the same app across staging, prod, local dev, and demo tenants.
  • Microsoft Entra ID (Azure AD) revokes tokens after 90 days of inactivity by default. Microsoft often embeds an AADSTS code in error_description — AADSTS70008 and AADSTS700082 indicate the refresh token expired due to inactivity.

Reused authorization codes

Authorization codes are strictly single-use. RFC 6749 recommends a short authorization-code lifetime and says codes must not be used more than once. If your callback handler retries a failed token exchange without obtaining a new code — say, due to a network timeout — you will get invalid_grant. Some providers (Google, Microsoft) will also revoke any tokens already issued for that code.

Wrong redirect URI at token exchange

The spec explicitly includes redirect URI mismatch inside invalid_grant if the redirect_uri in the token request no longer matches the one used in the authorization request. If you sent redirect_uri in the authorize step, you must reuse the exact same value in the token exchange. This trips up teams that reconstruct the URI from request headers instead of using a stored constant.

Clock skew

OAuth providers validate timestamps on requests. If your server's clock is more than a few seconds off, token requests will fail silently. The fix: ensure your servers sync via NTP. This is a one-minute fix that prevents hours of debugging.

# Check clock skew on Linux
timedatectl status
# Force NTP sync
sudo timedatectl set-ntp true

The fix for invalid_grant at scale

The only permanent fix for a revoked refresh token is re-authentication. Do not put invalid_grant on a hot retry loop. Treat it as permanent until a fresh user authorization proves otherwise.

type OAuthDecision = 'reauth' | 'fix_config' | 'retry'
 
export function classifyOAuthFailure(status: number, body: any): OAuthDecision {
  const code = body?.error
 
  if (code === 'invalid_grant') return 'reauth'
  if (code === 'invalid_client' || code === 'unauthorized_client') return 'fix_config'
  if (code === 'temporarily_unavailable' || status >= 500) return 'retry'
 
  return 'fix_config'
}

Your application needs to:

  1. Detect the invalid_grant error on the refresh attempt.
  2. Preserve the raw error body for debugging.
  3. Mark the account as needing re-authorization.
  4. Notify the end user through your UI or a webhook.
  5. Stop retrying. No amount of retry will fix a revoked token. Continuing to hammer the token endpoint will just burn your rate limit budget.

At Truto, when a refresh attempt fails with a non-retryable error (HTTP 401, 403, or invalid_grant), the system marks the integrated account as needs_reauth, fires an integrated_account:authentication_error webhook event, and stops scheduling further refresh alarms. Server errors (HTTP 500+), on the other hand, trigger a retry a few hours later because those are usually transient.

redirect_uri_mismatch: The Byte-for-Byte Trap

redirect_uri_mismatch means the redirect URI in your authorization request does not exactly match what you registered with the OAuth provider. "Exactly" means byte-for-byte. No forgiveness.

OAuth 2.0 requires an exact string match between the redirection URI used in the request and the one registered with the provider. This includes case sensitivity and trailing slashes.

Here is what will break you:

Registered URI Sent URI Result
https://app.com/callback https://app.com/callback/ Fail — trailing slash
https://app.com/callback http://app.com/callback Fail — http vs https
https://app.com/Auth/Callback https://app.com/auth/callback Fail — case mismatch
https://app.com:443/callback https://app.com/callback Fail on some providers — port

GitHub's Authorization callback URL is case-sensitive. This has bitten countless developers integrating with Azure AD B2C using GitHub as an identity provider, where Azure generates a lowercase callback URL but GitHub expects the original casing.

Provider-specific redirect URI quirks

  • Google requires HTTPS for all redirect URIs in production. http://localhost is allowed in development, but nothing else over plain HTTP.
  • For GitHub, if the redirect_uri is left out, GitHub will redirect users to the callback URL configured in the OAuth app settings. If provided, the redirect URL's host and port must exactly match the callback URL. GitHub OAuth apps also only allow a single callback URL, which adds operational friction when you have multiple environments.
  • Microsoft has a separate error code for this: AADSTS50011. Their error messages are actually helpful here, which is a pleasant exception.

The fix: one canonical redirect URI

Pick one canonical REDIRECT_URI per environment and use that exact string in both the authorize URL and the token exchange. Do not rebuild it from request headers unless you fully control proxy behavior.

const REDIRECT_URI = process.env.OAUTH_REDIRECT_URI!.trim()
 
export function buildAuthorizeUrl(client: OAuthClient) {
  return client.authorizeURL({
    redirect_uri: REDIRECT_URI,
    response_type: 'code'
  })
}
 
export function exchangeCode(client: OAuthClient, code: string) {
  return client.getToken({
    code,
    redirect_uri: REDIRECT_URI
  })
}

The multi-environment redirect URI headache

If you are building integrations across dev, staging, and production environments, managing redirect URIs for dozens of OAuth providers becomes a real operational burden. You end up registering separate URIs for every environment in every provider's dashboard.

A better pattern is to use a unified callback proxy — a single, stable redirect URI that routes the OAuth callback to the correct environment based on the state parameter. This is what Truto does: instead of managing hundreds of different URIs across SaaS dashboards, all callbacks route through a single endpoint, and the environment context is decoded from the encrypted state parameter. For a deeper look at how we secure the OAuth initiation flow, see our post on secure OAuth lifecycles and CSRF protection.

invalid_client vs unauthorized_client: Identity vs Permission

These two errors look similar but mean fundamentally different things.

invalid_client = "I don't know who you are."

This fires during the POST /token exchange when the provider cannot authenticate your application. Common causes:

  • Wrong client_id or client_secret. Copy-paste errors are more common than anyone admits. Some providers regenerate secrets on rotation without warning.
  • Wrong token-endpoint auth method. RFC 6749 says client credentials are normally sent with HTTP Basic auth and that request-body credentials are only an alternative for clients that cannot use Basic — and even then body credentials are not recommended. Some providers (like Salesforce) expect client credentials as a Base64-encoded Authorization: Basic header. Others want them in the request body as form parameters. Sending them the wrong way gives you invalid_client, not a helpful error message about the format.
  • Expired secrets. Microsoft Entra ID secrets have a configurable expiry (default: 2 years). When they expire, every integration silently breaks.
// What the provider expects (Basic auth in header):
Authorization: Basic base64(client_id:client_secret)

// What you sent (credentials in body):
client_id=abc&client_secret=xyz
// Result: invalid_client

unauthorized_client = "I know who you are, but you can't do that."

This means the provider recognized your app but the requested grant type or scope is not permitted. Common causes:

  • Grant type not enabled. Your app is configured for authorization_code only, but your code is attempting client_credentials. ServiceNow, for example, blocks the client credentials grant type by default — you have to explicitly enable it by setting a system property.
  • Restricted scopes. You are requesting a scope your app has not been approved for. Google's OAuth consent screen has a verification process for sensitive scopes; requesting unverified scopes will get you unauthorized_client.
  • Admin policy restrictions. In enterprise environments, workspace admins can restrict which grant types and scopes are available to connected apps. Salesforce org admins can override your app's default refresh token policy — if they do this, you may see every token refresh for users of that org fail.
Info

If you are on client_credentials, do not wait around for a refresh token. RFC 6749 says a refresh token should not be included in that response. When the access token expires, request a new one directly.

The debugging step is straightforward: check your app settings in the provider's developer portal. Verify the exact grant types enabled and the scopes approved. Do not assume your registration is correct because it worked last month — admin policies change.

The Final Boss: Token Refresh Race Conditions

Single-threaded OAuth works fine. The problems start when you have multiple processes — background sync jobs, webhook handlers, API requests from different users — all trying to refresh the same token at the same time.

Here is the scenario that breaks things:

sequenceDiagram
    participant Job A
    participant Job B
    participant Auth Server
    participant Database

    Job A->>Database: Read token (expired)
    Job B->>Database: Read token (expired)
    Job A->>Auth Server: Refresh with token_v1
    Job B->>Auth Server: Refresh with token_v1
    Auth Server-->>Job A: New token_v2 + new refresh_token_v2
    Auth Server-->>Job B: Error (token_v1 already used)
    Job A->>Database: Store token_v2
    Job B->>Database: Mark account as needs_reauth

If the provider rotates refresh tokens on every use (as Salesforce now optionally supports), both refresh requests use the same old refresh token. The first succeeds and gets a new token pair. The second fails because the old refresh token has been invalidated. Your system then incorrectly marks the account for re-authentication even though a valid token exists. Managing this is a distributed systems problem, not just a simple database update.

RFC 6749 allows the server to return a new refresh token and says the client must discard the old one. The OAuth security BCP describes how rotation and reuse detection can revoke the active token family after reuse — meaning concurrent refreshes are not just noisy, they can strand the connection entirely.

Danger

If your refresh path is not single-flight per connected account, you do not have a token refresh system. You have a race.

The fix is a mutex lock per account. Only one refresh operation should run at a time for a given connected account. Concurrent callers wait for the in-progress refresh and receive the same result.

const refreshLocks = new Map<string, Promise<Token>>()
 
export async function getFreshToken(accountId: string): Promise<Token> {
  if (refreshLocks.has(accountId)) {
    return refreshLocks.get(accountId)!
  }
 
  const op = (async () => {
    const current = await tokenStore.get(accountId)
 
    if (!expiresWithin(current.expiresAt, 30)) {
      return current
    }
 
    const next = await oauth.refresh({
      refresh_token: current.refreshToken
    })
 
    const merged = {
      ...current,
      ...next,
      refreshToken: next.refreshToken ?? current.refreshToken
    }
 
    await tokenStore.put(accountId, merged)
    return merged
  })()
 
  refreshLocks.set(accountId, op)
 
  try {
    return await op
  } finally {
    refreshLocks.delete(accountId)
  }
}

The key design principles:

  • One refresh at a time. Use a lock keyed by the connected account ID. If a second caller hits the lock, it awaits the existing operation instead of starting its own.
  • Timeout the lock. If a refresh hangs (slow provider, network issue), a 30-second timeout alarm should release the lock so future callers are not blocked forever.
  • Check expiry with a buffer. Refresh proactively before the token is dead. Waiting until it has already expired guarantees more 401s in flight.
  • Merge, don't replace. Some providers do not return the refresh token in the refresh response — they only include it in the initial exchange. Your token storage logic should merge the new response with the existing token, not overwrite it. Otherwise, you will lose the refresh token.

For a full deep dive into the distributed systems approach to this problem, including proactive alarm-based refresh and Durable Object-based mutex locks, see our engineering post on reliable token refreshes at scale.

How Truto Automates the OAuth Lifecycle

At Truto, we maintain OAuth connections to over 200 SaaS platforms. Every error pattern described above has hit us in production — repeatedly. The goal is not to pretend vendors behave consistently. The goal is to contain the inconsistency in one place.

Proactive refresh alarms

We do not wait for tokens to expire. When a token is stored, the system schedules a refresh alarm 60 to 180 seconds before expiry (randomized to spread load). This means tokens are renewed in the background before any API request needs them. If the proactive refresh fails, the system retries on a schedule for transient errors and stops entirely for permanent failures like invalid_grant.

Distributed mutex for refresh operations

Each connected account gets its own mutex-protected refresh operation. When multiple sync jobs or API requests try to refresh the same token concurrently, only the first actually executes the refresh. All others await the same result. A 30-second timeout alarm prevents stuck locks from blocking future operations.

Normalized error handling with JSONata expressions

Here is a reality most developers underestimate: third-party APIs do not agree on how to report errors. Some providers return 200 OK with an error buried in the JSON body. Others return semantically wrong HTTP status codes. Our system uses configurable JSONata expressions per integration to normalize these inconsistent error responses into standard HTTP semantics.

For example, when Slack returns {"ok": false, "error": "token_expired"} with a 200 status, the error expression maps it to a proper 401. When Freshdesk returns 429 without a Retry-After header (meaning the plan lacks API access, not an actual rate limit), the expression remaps it to 402.

This normalization is what makes reauth detection reliable. Without it, a Slack token expiration would silently pass through as a successful response. That is the difference between a reconnect banner and a silent data bug.

Automated reauth webhooks

When a connected account enters needs_reauth status, the system fires an integrated_account:authentication_error webhook. Your application can listen for this event to prompt the user through re-authorization without waiting for a support ticket. If a subsequent API call or refresh succeeds, the account is automatically reactivated and an integrated_account:reactivated webhook fires. Re-auth becomes an event, not a mystery.

A stable callback surface

Centralizing callback handling through a unified proxy reduces config drift across vendor dashboards — which matters when providers like GitHub OAuth apps only allow a single callback URL. One stable redirect URI means fewer places where a stray slash can break sign-in.

Info

Honesty check: A unified API platform like Truto handles all of this for you, but it will not repeal provider weirdness. You still live with consent screen reviews, scope mistakes, vendor outages, and customers revoking access at terrible times. What it does change is the maintenance cost. You stop rewriting the same auth state machine in every service. If your product only integrates with one or two providers, building your own OAuth handling with the patterns described here may be simpler. The value of a managed solution scales with the number of providers you need to support.

A Checklist Before You Pull Your Hair Out

When an OAuth error lands in your logs, run through this list before you go deeper:

  • redirect_uri: Is it a 1:1 string match? Check trailing slashes, protocol, case, port. Use one canonical URI per environment in both authorize and token requests.
  • Authorization code: Has it already been exchanged? Codes are single-use. Never retry the same code exchange.
  • client_id and client_secret: Are they current? Check the provider dashboard, not your env vars.
  • Server clock: Is NTP running? Even 30 seconds of drift can cause failures.
  • Credential delivery method: Does the provider expect Basic auth header or form body? Check the docs.
  • Grant type: Is authorization_code, client_credentials, or refresh_token enabled for your app?\
  • Token limits: Have you exceeded the provider's refresh token quota (Google: 50, Salesforce: 5)?
  • Refresh token rotation: Is the provider issuing new refresh tokens on each refresh? Are you storing them?
  • Admin policies: Has a customer's admin changed token policies, revoked app access, or restricted scopes?
  • Testing mode: Is your Google OAuth app still in testing status? Tokens expire after 7 days.
  • Error normalization: Are remote provider errors normalized before they hit business logic? Slack-style 200s will fool naive status-code checks.

Most OAuth errors are not deep architectural bugs. They are configuration mismatches and provider-specific policy gotchas. But at scale — when you are managing connections to dozens of SaaS platforms for hundreds of customers — the manual checklist breaks down. That is where automated token lifecycle management, distributed refresh locking, and error normalization stop being nice-to-haves and become infrastructure requirements.

Fix the immediate mismatch, then fix the architecture that let one revoked token take down a whole integration.

FAQ

What causes the OAuth 2.0 invalid_grant error?
invalid_grant is a catch-all error typically caused by expired or revoked refresh tokens, reused authorization codes, clock skew between your server and the provider, a redirect URI mismatch at token exchange, or provider-specific policies like Google's 50-token limit and Salesforce's 5-token-per-user cap. Do not retry it — treat it as permanent until the user reauthorizes.
How do I fix redirect_uri_mismatch in OAuth?
The redirect URI in your authorization request must be a byte-for-byte exact match with the one registered in the provider's dashboard. Check for trailing slashes, http vs https, case sensitivity, and port number mismatches. Use one canonical REDIRECT_URI constant per environment in both the authorize URL and the token exchange.
What is the difference between invalid_client and unauthorized_client?
invalid_client means the OAuth server cannot authenticate your application — wrong client_id, wrong secret, or wrong auth method (Basic header vs form body). unauthorized_client means it recognizes your app but you are not permitted to use the requested grant type or scope.
How do I prevent OAuth token refresh race conditions?
Use a single-flight mutex lock keyed per connected account so only one refresh operation runs at a time. Concurrent callers should await the in-progress operation instead of triggering duplicate requests. Merge token responses instead of replacing them, since not every provider returns a new refresh token on every refresh.

More from our Blog