Skip to main content

Overview

This guide covers common issues you might encounter when implementing OAuth 2.0, along with their solutions and prevention strategies.
Quick Debug Tip: Most OAuth issues fall into one of three categories: PKCE validation, state parameter mismatch, or token confusion. Check these first.
Remember: OAuth troubleshooting is about ensuring users can successfully authorize your application to act on their behalf. Technical issues may prevent users from granting or using the permissions they intend to provide.

Common Errors

Error Response:
{
  "error": "invalid_request",
  "error_description": "Missing required parameter: code_challenge"
}
Causes:
  • Missing PKCE parameters (code_challenge, code_challenge_method)
  • Missing response_type or incorrect value
  • Missing state parameter
  • Missing client_id or redirect_uri
Solutions:
  1. Verify all required parameters are present
  2. Check parameter names match exactly (case-sensitive)
  3. Ensure PKCE parameters use correct encoding (base64url)
Example Fix:
// ❌ Wrong - missing parameters
const params = new URLSearchParams({
  client_id: 'abc123'
});

// ✅ Correct - all required parameters
const params = new URLSearchParams({
  response_type: 'code',
  client_id: 'abc123',
  redirect_uri: 'https://yourapp.com/callback',
  state: generateState(),
  code_challenge: generateCodeChallenge(verifier),
  code_challenge_method: 'S256'
});
Error Response:
{
  "error": "invalid_grant",
  "error_description": "Authorization code has expired or already been used"
}
Causes:
  • Authorization code already used (codes are single-use)
  • Code expired (10-minute lifetime)
  • Code_verifier doesn’t match code_challenge
  • Too much time between OAuth steps
Solutions:
  1. Restart OAuth flow from Step 1
  2. Don’t reuse authorization codes
  3. Verify PKCE verifier matches the original challenge
  4. Complete token exchange within 10 minutes
Example Fix:
// ❌ Wrong - reusing code
const tokens1 = await exchangeCode(code, verifier);
const tokens2 = await exchangeCode(code, verifier); // ERROR

// ✅ Correct - use code once
const tokens = await exchangeCode(code, verifier);
// Store tokens, don't exchange again
Error Response:
{
  "error": "invalid_client",
  "error_description": "Client authentication failed"
}
Causes:
  • Incorrect x-client-key or x-secret-key
  • Using sandbox keys in production (or vice versa)
  • Keys revoked or expired
  • Missing required headers
Solutions:
  1. Verify both client key and secret key are correct
  2. Ensure you’re using keys for the correct environment
  3. Check headers are named exactly: x-client-key and x-secret-key
  4. Contact support if keys need to be regenerated
Example Fix:
// ❌ Wrong - incorrect header names
headers: {
  'client-key': 'pk_abc123',
  'secret-key': 'sk_xyz789'
}

// ✅ Correct - proper header names
headers: {
  'x-client-key': 'pk_abc123',
  'x-secret-key': 'sk_xyz789'
}
Symptom: Client-side validation fails when comparing state parameters.Causes:
  • State not stored correctly in session
  • State modified during OAuth flow
  • User restored from different session/device
  • CSRF attack attempt
Solutions:
  1. Store state in secure session storage
  2. Validate state exactly (case-sensitive)
  3. Don’t allow state to be modified
  4. Restart OAuth if state mismatch occurs
Example Fix:
// ❌ Wrong - storing in localStorage (can be modified)
localStorage.setItem('oauth_state', state);

// ✅ Correct - using secure session
sessionStorage.setItem('oauth_state', state);

// Validation
const savedState = sessionStorage.getItem('oauth_state');
if (returnedState !== savedState) {
  // Don't proceed - possible CSRF attack
  throw new Error('State mismatch - please restart login');
}
Error Response:
{
  "error": "invalid_request",
  "error_description": "redirect_uri does not match whitelisted URI"
}
Causes:
  • Redirect URI not whitelisted in environment config
  • URI doesn’t match exactly (protocol, domain, path)
  • Trailing slash mismatch
  • Query parameters in URI
Solutions:
  1. Contact admin to whitelist exact redirect URI
  2. Ensure exact match including protocol (https://)
  3. Don’t include query parameters in redirect_uri
  4. Match trailing slashes exactly
Example Fix:
// ❌ Wrong - different from whitelisted URI
redirect_uri: 'http://app.example.com/callback'  // Missing 's' in https
redirect_uri: 'https://app.example.com/callback/'  // Extra trailing slash
redirect_uri: 'https://app.example.com/auth/callback'  // Different path

// ✅ Correct - exact match
redirect_uri: 'https://app.example.com/callback'
Error Response:
{
  "error": "invalid_grant",
  "error_description": "OAuth session has expired"
}
Causes:
  • More than 10 minutes between initiation and authorization
  • User took too long on hosted UI
  • Network delays or user distraction
Solutions:
  1. Complete OAuth flow within 10 minutes
  2. Display countdown timer to users
  3. Restart flow if session expires
  4. Don’t pre-initiate OAuth too early
Example Fix:
const SESSION_TIMEOUT = 10 * 60 * 1000; // 10 minutes

class OAuthSession {
  private startTime = Date.now();

  isExpired(): boolean {
    return Date.now() - this.startTime >= SESSION_TIMEOUT;
  }

  getRemainingTime(): number {
    return Math.max(0, SESSION_TIMEOUT - (Date.now() - this.startTime));
  }
}

// Show warning when time is running out
if (session.getRemainingTime() < 60000) {
  showWarning('Please complete login within 1 minute');
}

// Restart if expired
if (session.isExpired()) {
  await restartOAuthFlow();
}
Symptom: 401 Unauthorized despite having tokens.Causes:
  • Using JWT token for API calls (should use access token)
  • Using login access token for final API calls
  • Mixing up tokens between OAuth steps
Solutions:
  1. JWT Token: Only in Step 3 request body (API mode)
  2. Login Access Token: Only in Step 3 Authorization header (API mode)
  3. Final Access Token: For all subsequent API calls
  4. Refresh Token: Only for token refresh endpoint
Example Fix:
// OAuth Flow Tokens (API Mode)
const { token: jwtToken } = await initiateOAuth();  // Step 1
const loginAccessToken = await login(email, password);  // Step 2

// Step 3: Generate auth code
await fetch('/v1/auth/oauth/authorize', {
  method: 'POST',
  headers: {
    'Authorization': `Bearer ${loginAccessToken}`,  // Login token in header
  },
  body: JSON.stringify({
    token: jwtToken  // JWT token in body
  })
});

// Step 4: Get final tokens
const { access_token: finalAccessToken } = await exchangeCode(...);

// All subsequent API calls
await fetch('/v1/users/me', {
  headers: {
    'Authorization': `Bearer ${finalAccessToken}`  // Final access token
  }
});
Common Mistake: Using the JWT token from Step 1 for API calls. The JWT is ONLY for OAuth flow coordination.
Error Response:
{
  "error": "invalid_grant",
  "error_description": "Code verifier validation failed"
}
Causes:
  • code_verifier doesn’t match original code_challenge
  • Incorrect SHA256 hashing
  • Base64url encoding issues
  • Using different verifier than original
Solutions:
  1. Verify SHA256 hash is correctly computed
  2. Use base64url encoding (not standard base64)
  3. Store and retrieve exact same verifier
  4. Check verifier length (43-128 characters)
Example Fix:
// ❌ Wrong - incorrect encoding
function generateChallenge(verifier: string): string {
  return crypto
    .createHash('sha256')
    .update(verifier)
    .digest('base64');  // Wrong - should be base64url
}

// ✅ Correct - base64url encoding
function generateCodeChallenge(verifier: string): string {
  return crypto
    .createHash('sha256')
    .update(verifier)
    .digest('base64url');  // Correct
}

// Verify stored verifier matches
const storedVerifier = sessionStorage.getItem('code_verifier');
if (!storedVerifier) {
  throw new Error('Code verifier not found - restart OAuth flow');
}
Error Response:
{
  "error": "otp_required",
  "message": "User has 2FA enabled",
  "requiresOtp": true
}
Causes:
  • User has OTP/2FA enabled
  • OTP code not included in login request
Solutions:
  1. Check login response for requiresOtp field
  2. Call OTP send endpoint to trigger code delivery
  3. Prompt user for OTP code
  4. Retry login with otpCode parameter
Example Fix:
async function handleLogin(email: string, password: string) {
  try {
    const response = await login(email, password);
    return response;
  } catch (error) {
    if (error.requiresOtp) {
      // Send OTP to user
      await sendOTP(email);

      // Prompt user for code
      const otpCode = await promptUserForOTP();

      // Retry with OTP
      return await login(email, password, otpCode);
    }
    throw error;
  }
}
Symptom: API calls fail with 401 immediately after getting access token.Causes:
  • Using wrong access token (e.g., login token instead of final token)
  • Token not being sent in Authorization header
  • Incorrect Bearer format
  • Missing x-client-key header
Solutions:
  1. Use access_token from token exchange response
  2. Include in Authorization header as “Bearer
  3. Always include x-client-key header
  4. Check for extra spaces or formatting issues
Example Fix:
// Get final tokens
const tokens = await exchangeCodeForTokens(code, verifier);

// ❌ Wrong - various issues
fetch('/v1/users/me', {
  headers: {
    'Authorization': tokens.access_token  // Missing "Bearer"
  }
});

fetch('/v1/users/me', {
  headers: {
    'Authorization': `Bearer ${tokens.access_token}`
    // Missing x-client-key
  }
});

// ✅ Correct - all required headers
fetch('/v1/users/me', {
  headers: {
    'Authorization': `Bearer ${tokens.access_token}`,
    'x-client-key': process.env.CLIENT_KEY
  }
});
Error Response:
{
  "error": "invalid_grant",
  "error_description": "Refresh token is invalid or expired"
}
Causes:
  • Refresh token expired (7 days)
  • Using old refresh token (tokens rotate)
  • Token was revoked
  • Incorrect grant_type parameter
Solutions:
  1. Check refresh token hasn’t expired
  2. Always use the newest refresh token (they rotate)
  3. Clear tokens and re-authenticate if refresh fails
  4. Ensure grant_type is “refresh_token”
Example Fix:
// ❌ Wrong - using old refresh token
const oldToken = getStoredRefreshToken();
const newTokens = await refreshAccessToken(oldToken);
// Still using oldToken next time - ERROR

// ✅ Correct - always update refresh token
const currentToken = getStoredRefreshToken();
const newTokens = await refreshAccessToken(currentToken);

// Update stored token immediately
await storeRefreshToken(newTokens.refresh_token);  // Important!
await storeAccessToken(newTokens.access_token);

// Handle expiration
if (refreshResponse.status === 401) {
  // Refresh token expired - need full re-auth
  await clearAllTokens();
  await redirectToLogin();
}

Debugging Strategies

1. Check Request/Response

1

Enable Network Logging

Use browser DevTools or a proxy to inspect requests:
// Add logging to your API client
async function apiCall(url: string, options: RequestInit) {
  console.log('Request:', { url, options });

  const response = await fetch(url, options);
  const data = await response.json();

  console.log('Response:', { status: response.status, data });

  return data;
}
2

Verify Headers

Ensure all required headers are present:
  • x-client-key: Always required
  • x-secret-key: Required for OAuth endpoints
  • Authorization: Required for authenticated endpoints
  • Content-Type: Required for POST/PUT requests
3

Check Response Codes

Different status codes indicate different issues:
  • 400: Bad request (missing/invalid parameters)
  • 401: Authentication failed (invalid token/credentials)
  • 403: Forbidden (valid auth, insufficient permissions)
  • 498: Invalid client key
  • 499: Missing client key

2. Validate PKCE Flow

function testPKCE() {
  const verifier = generateCodeVerifier();
  console.log('Verifier:', verifier);
  console.log('Length:', verifier.length); // Should be 43-128

  const challenge = generateCodeChallenge(verifier);
  console.log('Challenge:', challenge);

  // Verify character set
  const validChars = /^[A-Za-z0-9\-._~]+$/;
  console.log('Valid chars:', validChars.test(verifier));

  // Test round-trip
  const testChallenge = generateCodeChallenge(verifier);
  console.log('Matches:', challenge === testChallenge);
}

3. Trace Token Lifecycle

class TokenTracer {
  private events: Array<{ time: Date; event: string; data: any }> = [];

  log(event: string, data: any) {
    this.events.push({
      time: new Date(),
      event,
      data
    });
    console.log(`[${event}]`, data);
  }

  dump() {
    console.table(this.events);
  }
}

const tracer = new TokenTracer();

// Track token flow
tracer.log('OAUTH_INIT', { jwtToken: jwt.substring(0, 20) });
tracer.log('LOGIN', { accessToken: token.substring(0, 20) });
tracer.log('AUTH_CODE', { code: code.substring(0, 20) });
tracer.log('TOKEN_EXCHANGE', {
  accessToken: tokens.access_token.substring(0, 20),
  refreshToken: tokens.refresh_token.substring(0, 20)
});

// Review trace
tracer.dump();

Environment-Specific Issues

Sandbox vs Production

Sandbox Issues

  • HTTP allowed in sandbox only
  • Different client keys per environment
  • Sandbox data doesn’t transfer to production
  • Rate limits may differ

Production Issues

  • HTTPS strictly required
  • Redirect URIs must be whitelisted
  • More strict validation
  • Lower tolerance for errors

Regional Routing

If using US environment:
// Add x-us-env header for US routing
headers: {
  'x-client-key': process.env.CLIENT_KEY,
  'x-us-env': 'true'  // Required for US environment
}

Prevention Best Practices

1. Implement Comprehensive Error Handling

class OAuthError extends Error {
  constructor(
    public code: string,
    public description: string,
    public statusCode: number
  ) {
    super(description);
  }
}

async function handleOAuthError(response: Response) {
  const error = await response.json();

  switch (error.error) {
    case 'invalid_grant':
      // Restart OAuth flow
      await clearOAuthState();
      throw new OAuthError(
        error.error,
        'Authorization expired. Please login again.',
        response.status
      );

    case 'invalid_client':
      // Configuration issue
      logCritical('Invalid OAuth credentials');
      throw new OAuthError(
        error.error,
        'Authentication configuration error. Please contact support.',
        response.status
      );

    case 'invalid_request':
      // Developer error
      logError('OAuth request invalid', error);
      throw new OAuthError(
        error.error,
        error.error_description || 'Invalid request',
        response.status
      );

    default:
      throw new OAuthError(
        error.error || 'unknown_error',
        error.error_description || 'An unexpected error occurred',
        response.status
      );
  }
}

2. Add Validation Checks

function validateOAuthState() {
  const checks = [
    {
      name: 'Code Verifier',
      test: () => {
        const verifier = getCodeVerifier();
        return verifier && verifier.length >= 43 && verifier.length <= 128;
      }
    },
    {
      name: 'State Parameter',
      test: () => {
        const state = getState();
        return state && state.length >= 16;
      }
    },
    {
      name: 'Redirect URI',
      test: () => {
        const uri = getRedirectUri();
        return uri && uri.startsWith('https://');
      }
    }
  ];

  const failed = checks.filter(check => !check.test());

  if (failed.length > 0) {
    console.error('Validation failed:', failed.map(f => f.name));
    return false;
  }

  return true;
}

3. Set Up Monitoring

// Track OAuth success/failure rates
function trackOAuthMetric(event: string, success: boolean, error?: string) {
  analytics.track('OAuth Event', {
    event,
    success,
    error,
    timestamp: Date.now(),
    environment: process.env.NODE_ENV
  });
}

// Usage
try {
  await initiateOAuth();
  trackOAuthMetric('initiate', true);
} catch (error) {
  trackOAuthMetric('initiate', false, error.message);
}

Testing Tools

OAuth Flow Tester

import { describe, it, expect } from 'vitest';

describe('OAuth Flow', () => {
  it('generates valid PKCE parameters', () => {
    const verifier = generateCodeVerifier();
    expect(verifier.length).toBeGreaterThanOrEqual(43);
    expect(verifier.length).toBeLessThanOrEqual(128);
    expect(verifier).toMatch(/^[A-Za-z0-9\-._~]+$/);

    const challenge = generateCodeChallenge(verifier);
    expect(challenge).toBeDefined();
    expect(challenge.length).toBeGreaterThan(0);
  });

  it('validates state parameter', () => {
    const state = generateState();
    expect(state.length).toBeGreaterThanOrEqual(16);
  });

  it('handles token refresh correctly', async () => {
    const tokens = await refreshAccessToken('valid_refresh_token');
    expect(tokens.access_token).toBeDefined();
    expect(tokens.refresh_token).toBeDefined();
  });
});

Getting Additional Help

Check API Status

Verify there are no ongoing incidents or maintenance:
  • Check status page
  • Review error rates in dashboard
  • Confirm environment availability

Review Logs

Enable detailed logging to capture:
  • Request/response bodies
  • Header values (sanitize secrets!)
  • Timing information
  • Error stack traces

Contact Support

When contacting support, include:
  • Error messages and codes
  • Request/response examples (sanitized)
  • Environment (sandbox/production)
  • Approximate timestamp of issues
  • Client key (NOT secret key)

Community Resources

Get help from the community:
  • Search documentation
  • Review example implementations
  • Check for similar issues
  • Share non-sensitive code samples

Quick Reference

OAuth Error Codes

Error CodeMeaningTypical CauseSolution
invalid_requestMissing/invalid paramsIncorrect API callCheck all required parameters
invalid_grantCode invalid/expiredUsed code twice or expiredRestart OAuth flow
invalid_clientAuth failedWrong keysVerify client credentials
access_deniedUser deniedUser clicked denyExpected behavior, handle gracefully
unauthorized_clientClient not authorizedOAuth not enabledContact administrator

HTTP Status Codes

CodeMeaningAction
400Bad RequestCheck request parameters
401UnauthorizedCheck authentication tokens
403ForbiddenCheck permissions/scope
404Not FoundCheck endpoint URL
498Invalid Client KeyVerify x-client-key header
499Missing Client KeyAdd x-client-key header
500Server ErrorRetry or contact support

Next Steps