Salesforce

Common Salesforce OAuth Errors And How to Fix Them

7 mins read Salesforce 11 Aug 2026

Your Salesforce integration was working perfectly yesterday. Today, users are getting "401 Unauthorized" errors, and you're scrambling to figure out what changed.

Salesforce OAuth errors are one of the most common problems developers face when building integrations. A small misconfiguration in your OAuth flow can break authentication, expose security vulnerabilities, or cause mysterious failures that only appear hours after deployment.

The challenge with OAuth 2.0 is that it's simultaneously simple in concept and complex in implementation. The protocol itself is straightforward: obtain a token, use it to authenticate API calls, refresh it when it expires. But between those steps lie dozens of security considerations, configuration details, and error conditions that can derail your integration.

In this guide, we'll walk through the seven most common Salesforce OAuth errors we encounter, explain what actually breaks when you make these mistakes, and show you exactly how to fix them with code examples.

Understanding Salesforce OAuth 2.0

Before diving into specific errors, let's establish a foundation. OAuth 2.0 is Salesforce's authentication standard. Instead of passing usernames and passwords with every API request, OAuth uses tokens like temporary credentials that grant limited access to Salesforce resources.

The Basic OAuth Flow

  1. Your application requests authorization from Salesforce
  2. User grants permission through Salesforce's login screen
  3. Salesforce returns an authorization code
  4. Your application exchanges this code for an access token
  5. You use the access token to authenticate API requests
  6. When the access token expires, you use the refresh token to get a new one

Why OAuth Matters

OAuth isn't just about authentication. It's about secure, delegated access. When implemented correctly, OAuth ensures:

  • User credentials never pass through your application
  • Access tokens have limited lifespans (typically 2 hours)
  • Refresh tokens allow long-term access without storing passwords
  • Users can revoke access at any time
  • Your integration follows enterprise security standards

Common OAuth Flows in Salesforce

  • Authorization Code Flow: Most secure, recommended for web applications
  • Client Credentials Flow: For server-to-server integrations without user interaction
  • Username-Password Flow: Simplified but less secure, only for trusted applications
  • JWT Bearer Flow: For headless integrations with certificate-based authentication

Now let's examine where OAuth implementations typically go wrong.

Salesforce OAuth Security Requirements (Effective May 11, 2026)

As of May 11, 2026, Salesforce enforces mandatory OAuth security controls for all AppExchange partners and Connected Apps using Authorization Code flows. If your integration isn't compliant, you risk AppExchange de-listing and suspension.

Three Mandatory Requirements Now Enforced

Requirement 1

PKCE (Proof Key for Code Exchange)

Required for all Authorization Code flows. Enable in your Connected App settings and implement code verifier/challenge generation in your OAuth flow.

Requirement 2

Refresh Token Rotation (RTR) - Critical Code Change

Every refresh token can only be used once. Salesforce returns a new refresh token with each access token and invalidates the old one.

Requirement 3

IP Allowlisting

Refresh tokens can only be redeemed from registered IP addresses. Cloud-hosted integrations need a static IP proxy service.

Your code MUST handle this:

Incorrect — Fails with RTR
const data = await response.json();
this.accessToken = data.access_token;
// Not saving new refresh token - next refresh will fail
Correct — RTR-Compliant
const data = await response.json();
this.accessToken = data.access_token;

// CRITICAL: Persist new refresh token
if (data.refresh_token) {
  await updateEncryptedRefreshToken(userId, data.refresh_token);
}

If your code doesn't persist new refresh tokens, authentication will fail.

Additional Requirements

  • 30-day idle timeout on refresh tokens
  • IP monitoring (strongly recommended)

Who Must Comply

  • AppExchange ISVs with packaged Connected Apps
  • Any integration using Authorization Code flow with refresh tokens

Exempt

  • JWT Bearer flow (no refresh tokens issued)
  • Client Credentials flow
  • Internal non-AppExchange apps

If You're Not Yet Compliant — Immediate Actions

  • Enable PKCE and RTR in your Connected App settings
  • Update token refresh code to persist new refresh tokens
  • Register IP addresses in your allowlist
  • Test thoroughly in sandbox before production

Consequences of non-compliance

  • AppExchange de-listing
  • OAuth token suspension
  • Integration failure in customer orgs
Error #1

Storing Refresh Tokens in Plain Text

The Problem

Developers store refresh tokens in plain text databases, configuration files, or environment variables without encryption. This is one of the most dangerous OAuth security vulnerabilities because refresh tokens provide long-term access to Salesforce data.

What Actually Breaks

Consider this scenario: A developer stores refresh tokens in a PostgreSQL database without encryption. The database credentials accidentally get committed to a public GitHub repository. An attacker gains access, extracts all refresh tokens, and can now access Salesforce data for every connected user, until tokens are manually revoked.

Even without a security breach, plain text tokens create compliance issues. If you're handling sensitive data under GDPR, HIPAA, or PCI-DSS, storing authentication credentials in plain text is a violation that can result in significant penalties.

The Impact

  • Permanent unauthorized access if tokens are compromised
  • No audit trail of who accessed tokens when
  • Compliance violations
  • Inability to detect token theft
  • Complete compromise of all connected Salesforce orgs

The Fix

Always encrypt refresh tokens at rest using strong encryption. Here's the right way:

Incorrect — Plain Text Storage
// NEVER DO THIS
const saveToken = async (userId, refreshToken) => {
  await database.query(
    'INSERT INTO tokens (user_id, refresh_token) VALUES (?, ?)',
    [userId, refreshToken]
  );
};
Correct — Encrypted Storage
const crypto = require('crypto');

// Use environment variable for encryption key (not hardcoded)
const ENCRYPTION_KEY = process.env.TOKEN_ENCRYPTION_KEY; // Must be 32 bytes
const ALGORITHM = 'aes-256-gcm';

const encryptToken = (token) => {
  const iv = crypto.randomBytes(16);
  const cipher = crypto.createCipheriv(ALGORITHM, Buffer.from(ENCRYPTION_KEY, 'hex'), iv);

  let encrypted = cipher.update(token, 'utf8', 'hex');
  encrypted += cipher.final('hex');

  const authTag = cipher.getAuthTag();

  // Return IV + authTag + encrypted token (all needed for decryption)
  return iv.toString('hex') + ':' + authTag.toString('hex') + ':' + encrypted;
};

const decryptToken = (encryptedData) => {
  const parts = encryptedData.split(':');
  const iv = Buffer.from(parts[0], 'hex');
  const authTag = Buffer.from(parts[1], 'hex');
  const encrypted = parts[2];

  const decipher = crypto.createDecipheriv(ALGORITHM, Buffer.from(ENCRYPTION_KEY, 'hex'), iv);
  decipher.setAuthTag(authTag);

  let decrypted = decipher.update(encrypted, 'hex', 'utf8');
  decrypted += decipher.final('utf8');

  return decrypted;
};

const saveToken = async (userId, refreshToken) => {
  const encryptedToken = encryptToken(refreshToken);

  await database.query(
    'INSERT INTO tokens (user_id, encrypted_refresh_token) VALUES (?, ?)',
    [userId, encryptedToken]
  );
};

Additional Security Measures

  • Store encryption keys in a secrets manager (AWS Secrets Manager, Azure Key Vault)
  • Rotate encryption keys periodically
  • Implement access logging for token retrieval
  • Use database-level encryption as a second layer
  • Never log refresh tokens, even in debug mode
Error #2

Using Username-Password Flow in Production

The Problem

The Username-Password OAuth flow seems convenient, just send username and password to get a token. But this flow was designed for testing and migration scenarios, not production systems. Using it in production defeats the entire purpose of OAuth.

What Actually Breaks

Imagine this scenario: An integration uses the username-password flow in production. A developer hard-codes Salesforce credentials into the application configuration. When an employee leaves the company, IT changes their Salesforce password. Suddenly, the integration breaks, and nobody knows why because the credentials are buried in configuration files.

Worse, the username-password flow bypasses multi-factor authentication. If your Salesforce org requires MFA (which it should), this flow creates a security backdoor that attackers can exploit.

The Impact

  • Bypasses MFA security requirements
  • Stores user passwords in your system (major security risk)
  • Integration breaks when passwords are changed
  • No audit trail of which user performed which action
  • Violates OAuth security principles
  • May fail compliance audits

The Fix

Use Authorization Code flow for user-facing applications or JWT Bearer flow for server-to-server integrations.

Incorrect — Username-Password Flow
// NEVER DO THIS IN PRODUCTION
const getAccessToken = async (username, password) => {
  const response = await fetch('https://login.salesforce.com/services/oauth2/token', {
    method: 'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body: new URLSearchParams({
      grant_type: 'password',
      client_id: process.env.CLIENT_ID,
      client_secret: process.env.CLIENT_SECRET,
      username: username, // Security risk: handling passwords
      password: password + process.env.SECURITY_TOKEN
    })
  });

  return await response.json();
};
Correct — Authorization Code Flow
// CORRECT APPROACH FOR WEB APPS
// Step 1: Redirect user to Salesforce login
const initiateOAuth = (req, res) => {
  const authUrl = 'https://login.salesforce.com/services/oauth2/authorize?' +
    new URLSearchParams({
      response_type: 'code',
      client_id: process.env.CLIENT_ID,
      redirect_uri: process.env.REDIRECT_URI,
      state: generateRandomState() // CSRF protection
    });

  res.redirect(authUrl);
};

// Step 2: Handle callback and exchange code for token
const handleCallback = async (req, res) => {
  const { code, state } = req.query;

  // Verify state to prevent CSRF attacks
  if (!verifyState(state)) {
    return res.status(403).send('Invalid state parameter');
  }

  const response = await fetch('https://login.salesforce.com/services/oauth2/token', {
    method: 'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body: new URLSearchParams({
      grant_type: 'authorization_code',
      code: code,
      client_id: process.env.CLIENT_ID,
      client_secret: process.env.CLIENT_SECRET,
      redirect_uri: process.env.REDIRECT_URI
    })
  });

  const tokens = await response.json();

  // Store encrypted refresh token (see Error #1)
  await saveEncryptedToken(req.user.id, tokens.refresh_token);

  res.redirect('/dashboard');
};

When Username-Password Flow is Acceptable

Only use this flow for:

  • Local development and testing
  • One-time data migration scripts
  • Internal tools in tightly controlled environments
  • When explicitly approved by security team with documented justification

Never use it for customer-facing applications or production integrations.

Error #3

Not Implementing Token Refresh Logic

The Problem

Salesforce access tokens expire after 2 hours by default. Developers often implement OAuth to get the initial token but forget to handle token expiration. The integration works perfectly for 2 hours, then suddenly starts throwing 401 Unauthorized errors.

What Actually Breaks

Here's a common scenario: An integration syncs data between Salesforce and an external system every 4 hours. The first sync succeeds. The second sync fails with "Session expired or invalid" because the access token expired after 2 hours, and the code doesn't know how to refresh it.

Users report intermittent failures. Developers can't reproduce the issue because when they test, they get a fresh token that works for 2 hours. The problem only appears in production after the system has been running for a while.

The Impact

  • Integration failures after token expiration (unpredictable timing)
  • Users getting error messages they can't fix
  • Support tickets for "integration randomly stopped working"
  • Data sync gaps during token expiration windows
  • Manual intervention required to restart integration

The Fix

Implement automatic token refresh with proper error handling.

Incorrect — No Refresh Logic (breaks after 2 hours)
// BREAKS AFTER 2 HOURS
class SalesforceClient {
  constructor(accessToken) {
    this.accessToken = accessToken;
  }

  async makeAPICall(endpoint) {
    const response = await fetch(`https://yourinstance.salesforce.com${endpoint}`, {
      headers: {
        'Authorization': `Bearer ${this.accessToken}`
      }
    });

    // If token expired, this just returns error to user
    return await response.json();
  }
}
Correct — Automatic Token Refresh
// HANDLES TOKEN EXPIRATION GRACEFULLY
class SalesforceClient {
  constructor(userId) {
    this.userId = userId;
    this.accessToken = null;
    this.tokenExpiry = null;
  }

  async ensureValidToken() {
    // Check if token exists and hasn't expired
    if (this.accessToken && this.tokenExpiry > Date.now() + 60000) {
      return this.accessToken; // Token still valid (with 1-min buffer)
    }

    // Token expired or doesn't exist, refresh it
    return await this.refreshAccessToken();
  }

  async refreshAccessToken() {
    // Get encrypted refresh token from secure storage
    const encryptedRefreshToken = await getEncryptedToken(this.userId);
    const refreshToken = decryptToken(encryptedRefreshToken);

    const response = await fetch('https://login.salesforce.com/services/oauth2/token', {
      method: 'POST',
      headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
      body: new URLSearchParams({
        grant_type: 'refresh_token',
        refresh_token: refreshToken,
        client_id: process.env.CLIENT_ID,
        client_secret: process.env.CLIENT_SECRET
      })
    });

    const data = await response.json();

    if (!response.ok) {
      // Refresh token invalid - user needs to re-authenticate
      throw new Error('Refresh token expired. User must re-authorize.');
    }

    this.accessToken = data.access_token;
    this.tokenExpiry = Date.now() + (2 * 60 * 60 * 1000); // 2 hours

    return this.accessToken;
  }

  async makeAPICall(endpoint) {
    await this.ensureValidToken(); // Always check token before API call

    const response = await fetch(`https://yourinstance.salesforce.com${endpoint}`, {
      headers: {
        'Authorization': `Bearer ${this.accessToken}`
      }
    });

    // Handle token expiration during request
    if (response.status === 401) {
      // Token might have expired during request, try refresh once
      await this.refreshAccessToken();

      // Retry request with new token
      const retryResponse = await fetch(`https://yourinstance.salesforce.com${endpoint}`, {
        headers: {
          'Authorization': `Bearer ${this.accessToken}`
        }
      });

      return await retryResponse.json();
    }

    return await response.json();
  }
}

Best Practices for Token Refresh

  • Proactively refresh tokens before they expire (60-second buffer)
  • Implement retry logic if refresh fails
  • Handle refresh token expiration gracefully (prompt user to re-authenticate)
  • Log token refresh events for troubleshooting
  • Don't refresh on every API call—check expiration first
Error #4

Exposing Client Secrets in Frontend Code

The Problem

Client secrets are meant to be secret. But developers sometimes include them in JavaScript code, mobile apps, or any client-side code where users can access them. Once exposed, anyone can impersonate your application and access Salesforce data.

What Actually Breaks

Consider this scenario: A developer builds a React dashboard that connects to Salesforce. To simplify development, they hard-code the OAuth client secret into the frontend JavaScript bundle. The application ships to production. Anyone can open browser DevTools, view the JavaScript source, and extract the client secret.

With the client secret, an attacker can:

  • Impersonate your application
  • Generate access tokens for any user who's authorized your app
  • Access all Salesforce data your Connected App can access
  • Bypass your application's security controls

The Impact

  • Complete compromise of OAuth application
  • Unauthorized access to Salesforce data
  • No way to revoke access without resetting client secret (breaks all users)
  • Potential data breach
  • Compliance violations

The Fix

Never include client secrets in frontend code. Use a backend proxy for OAuth operations.

Incorrect — Client Secret in Frontend
// NEVER DO THIS - Client secret visible in browser
const getAccessToken = async (code) => {
  const response = await fetch('https://login.salesforce.com/services/oauth2/token', {
    method: 'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body: new URLSearchParams({
      grant_type: 'authorization_code',
      code: code,
      client_id: 'YOUR_CLIENT_ID', // OK to expose
      client_secret: 'YOUR_CLIENT_SECRET', // SECURITY BREACH
      redirect_uri: 'https://yourapp.com/callback'
    })
  });

  return await response.json();
};
Correct — Backend Proxy Pattern
// FRONTEND CODE (React, Vue, etc.)
// No client secret here!
const exchangeCodeForToken = async (code) => {
  // Call your own backend API
  const response = await fetch('/api/oauth/callback', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ code })
  });

  return await response.json();
};

// BACKEND CODE (Node.js, Python, etc.)
// Client secret stays on server
app.post('/api/oauth/callback', async (req, res) => {
  const { code } = req.body;

  // Validate code came from your OAuth flow
  if (!isValidOAuthCode(code)) {
    return res.status(400).json({ error: 'Invalid code' });
  }

  const response = await fetch('https://login.salesforce.com/services/oauth2/token', {
    method: 'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body: new URLSearchParams({
      grant_type: 'authorization_code',
      code: code,
      client_id: process.env.CLIENT_ID,
      client_secret: process.env.CLIENT_SECRET, // Safe on server
      redirect_uri: process.env.REDIRECT_URI
    })
  });

  const tokens = await response.json();

  // Store refresh token securely on server
  await saveEncryptedToken(req.user.id, tokens.refresh_token);

  // Return only access token to frontend (short-lived)
  res.json({
    access_token: tokens.access_token,
    instance_url: tokens.instance_url
  });
});

Additional Protection for Mobile Apps

For mobile applications, use PKCE (Proof Key for Code Exchange) flow, which doesn't require a client secret:

Correct — PKCE for Mobile (no client secret)
// Mobile App OAuth with PKCE (no client secret needed)
const initiateOAuthPKCE = async () => {
  // Generate code verifier and challenge
  const codeVerifier = generateRandomString(128);
  const codeChallenge = base64URLEncode(sha256(codeVerifier));

  // Store verifier for later
  await AsyncStorage.setItem('code_verifier', codeVerifier);

  const authUrl = 'https://login.salesforce.com/services/oauth2/authorize?' +
    new URLSearchParams({
      response_type: 'code',
      client_id: 'YOUR_CLIENT_ID', // Safe to include
      redirect_uri: 'yourapp://oauth/callback',
      code_challenge: codeChallenge,
      code_challenge_method: 'S256'
    });

  // Open browser for authentication
  await openBrowser(authUrl);
};
Error #5

Missing Redirect URI Validation

The Problem

The redirect URI is where Salesforce sends users after authentication. If you don't validate this URI properly, attackers can intercept authorization codes and gain access to user accounts.

What Actually Breaks

Here's the scenario: Your Connected App has https://yourapp.com/callback as the registered redirect URI. An attacker modifies the OAuth URL to use https://attacker.com/steal as the redirect URI. If Salesforce allows this (because you didn't restrict redirect URIs in the Connected App), the authorization code gets sent to the attacker's server. They exchange it for an access token and gain full access to the user's Salesforce data.

The Impact

  • Authorization code interception (OAuth hijacking)
  • Unauthorized access to user accounts
  • Account takeover attacks
  • Phishing opportunities (attacker controls redirect destination)
  • Token theft

The Fix

Configure exact redirect URIs in your Salesforce Connected App and validate them server-side.

Incorrect — No Validation
// DANGEROUS - Accepts any redirect URI
app.get('/oauth/authorize', (req, res) => {
  const redirectUri = req.query.redirect_uri; // User-controlled!

  const authUrl = 'https://login.salesforce.com/services/oauth2/authorize?' +
    new URLSearchParams({
      response_type: 'code',
      client_id: process.env.CLIENT_ID,
      redirect_uri: redirectUri // Attacker can set this to their server
    });

  res.redirect(authUrl);
});
Correct — Strict Validation
// SECURE - Validate redirect URI
const ALLOWED_REDIRECT_URIS = [
  'https://yourapp.com/callback',
  'https://yourapp.com/oauth/success',
  'https://staging.yourapp.com/callback' // For testing
];

app.get('/oauth/authorize', (req, res) => {
  const requestedRedirect = req.query.redirect_uri;

  // Validate redirect URI is in whitelist
  if (!ALLOWED_REDIRECT_URIS.includes(requestedRedirect)) {
    return res.status(400).json({
      error: 'Invalid redirect_uri',
      message: 'The redirect URI must be registered in the application'
    });
  }

  const authUrl = 'https://login.salesforce.com/services/oauth2/authorize?' +
    new URLSearchParams({
      response_type: 'code',
      client_id: process.env.CLIENT_ID,
      redirect_uri: requestedRedirect,
      state: generateAndStoreState(req.session.id) // CSRF protection
    });

  res.redirect(authUrl);
});

app.get('/oauth/callback', async (req, res) => {
  const { code, state } = req.query;

  // Verify state parameter to prevent CSRF
  if (!verifyState(req.session.id, state)) {
    return res.status(403).json({ error: 'Invalid state parameter' });
  }

  // Exchange code for token
  // ... token exchange logic ...
});

Salesforce Connected App Configuration

In your Salesforce Connected App settings:

  1. Set "Callback URL" to exact, complete URLs (not wildcards)
  2. Add only URLs you control
  3. Use HTTPS for production URLs
  4. Don't use localhost URLs in production Connected Apps
  5. Implement state parameter validation (CSRF protection)
Error #6

Hardcoding Salesforce Instance URLs

The Problem

Developers hardcode the Salesforce instance URL (like https://na50.salesforce.com) instead of using the dynamic instance URL returned during OAuth. When Salesforce migrates users to different instances, hardcoded URLs break.

What Actually Breaks

Imagine this scenario: Your integration hardcodes https://na50.salesforce.com as the API endpoint. Salesforce performs a maintenance operation and migrates your org to https://na100.salesforce.com. All your API calls start returning errors because you're hitting the wrong instance.

The Impact

  • Integration breaks during Salesforce instance migrations
  • API calls fail with cryptic errors
  • Requires code changes to fix (not just configuration)
  • Affects users unpredictably based on when their org migrates

The Fix

Always use the instance_url returned in the OAuth token response.

Incorrect — Hardcoded Instance
// BREAKS when Salesforce migrates instance
const fetchAccounts = async (accessToken) => {
  const response = await fetch('https://na50.salesforce.com/services/data/v60.0/query?q=SELECT+Id,Name+FROM+Account', {
    headers: { 'Authorization': `Bearer ${accessToken}` }
  });

  return await response.json();
};
Correct — Dynamic Instance URL
// WORKS regardless of instance
class SalesforceClient {
  constructor(accessToken, instanceUrl) {
    this.accessToken = accessToken;
    this.instanceUrl = instanceUrl; // From OAuth response
  }

  async fetchAccounts() {
    const response = await fetch(
      `${this.instanceUrl}/services/data/v60.0/query?q=SELECT+Id,Name+FROM+Account`,
      {
        headers: { 'Authorization': `Bearer ${this.accessToken}` }
      }
    );

    return await response.json();
  }
}

// Store instance URL along with tokens
const handleOAuthCallback = async (code) => {
  const response = await exchangeCodeForToken(code);

  await saveUserTokens({
    userId: getCurrentUserId(),
    accessToken: response.access_token,
    refreshToken: response.refresh_token,
    instanceUrl: response.instance_url // Store this!
  });
};
Error #7

Not Handling OAuth Error Responses

The Problem

OAuth can fail for many reasons: expired refresh tokens, revoked access, invalid client credentials, or network issues. Developers often assume OAuth calls will succeed and don't handle error responses properly.

What Actually Breaks

Here's a scenario: A user revokes access to your application from Salesforce settings. When your code tries to refresh the access token, Salesforce returns an error. Your code doesn't check for errors, treats the error response as a valid token, and passes it to API calls. Everything fails with cryptic error messages.

The Impact

  • Poor error messages for users ("Something went wrong")
  • No clear path for users to fix issues
  • Support tickets with insufficient debugging information
  • Cascading failures throughout the application

The Fix

Implement comprehensive error handling for all OAuth operations.

Incorrect — No Error Handling
// ASSUMES everything works
const refreshToken = async (refreshToken) => {
  const response = await fetch('https://login.salesforce.com/services/oauth2/token', {
    method: 'POST',
    body: new URLSearchParams({
      grant_type: 'refresh_token',
      refresh_token: refreshToken,
      client_id: process.env.CLIENT_ID,
      client_secret: process.env.CLIENT_SECRET
    })
  });

  const data = await response.json();
  return data.access_token; // What if this failed?
};
Correct — Comprehensive Error Handling
// HANDLES OAuth errors properly
class OAuthError extends Error {
  constructor(message, code, description) {
    super(message);
    this.code = code;
    this.description = description;
    this.name = 'OAuthError';
  }
}

const refreshToken = async (refreshToken) => {
  try {
    const response = await fetch('https://login.salesforce.com/services/oauth2/token', {
      method: 'POST',
      headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
      body: new URLSearchParams({
        grant_type: 'refresh_token',
        refresh_token: refreshToken,
        client_id: process.env.CLIENT_ID,
        client_secret: process.env.CLIENT_SECRET
      })
    });

    const data = await response.json();

    if (!response.ok) {
      // Parse Salesforce error response
      const errorCode = data.error;
      const errorDescription = data.error_description;

      // Handle specific error cases
      switch (errorCode) {
        case 'invalid_grant':
          throw new OAuthError(
            'Refresh token expired or revoked. User must re-authenticate.',
            'REFRESH_TOKEN_INVALID',
            errorDescription
          );

        case 'invalid_client':
          throw new OAuthError(
            'Client credentials are invalid. Check CLIENT_ID and CLIENT_SECRET.',
            'INVALID_CREDENTIALS',
            errorDescription
          );

        default:
          throw new OAuthError(
            'OAuth token refresh failed',
            errorCode.toUpperCase(),
            errorDescription
          );
      }
    }

    return {
      accessToken: data.access_token,
      instanceUrl: data.instance_url
    };

  } catch (error) {
    // Log error with context for debugging
    console.error('Token refresh failed:', {
      error: error.message,
      code: error.code,
      description: error.description,
      timestamp: new Date().toISOString()
    });

    // Re-throw with actionable message
    if (error instanceof OAuthError) {
      throw error;
    }

    throw new Error('Network error during token refresh. Please try again.');
  }
};

// Use with proper error handling
app.post('/api/salesforce/data', async (req, res) => {
  try {
    const client = new SalesforceClient(req.user.id);
    const data = await client.fetchData();

    res.json({ success: true, data });

  } catch (error) {
    if (error instanceof OAuthError && error.code === 'REFRESH_TOKEN_INVALID') {
      // Clear stored tokens
      await clearUserTokens(req.user.id);

      // Return specific error so frontend can prompt re-authentication
      return res.status(401).json({
        error: 'authentication_required',
        message: 'Your Salesforce connection expired. Please reconnect.',
        reconnect_url: '/oauth/authorize'
      });
    }

    // Generic error
    res.status(500).json({
      error: 'server_error',
      message: error.message
    });
  }
});

Salesforce OAuth Implementation Checklist

Use this checklist before deploying any Salesforce OAuth integration:

Security

  • Refresh tokens are encrypted at rest
  • Encryption keys stored in secrets manager (not code)
  • Client secrets never exposed in frontend code
  • Using Authorization Code or JWT Bearer flow (not username-password) in production
  • Redirect URIs validated against whitelist
  • State parameter implemented for CSRF protection
  • Tokens never logged (even in debug mode)

Token Management

  • Automatic token refresh logic implemented
  • Token expiration checked before each API call
  • Refresh token errors handled gracefully
  • User prompted to re-authenticate when refresh fails
  • Token refresh events logged for monitoring

Configuration

  • Instance URL used from OAuth response (not hardcoded)
  • All Connected App settings reviewed and locked down
  • Callback URLs registered exactly in Connected App
  • Scopes limited to minimum required permissions
  • Token expiration policies configured appropriately

Error Handling

  • All OAuth error responses handled
  • Specific error messages for different failure types
  • Network errors caught and retried
  • Users given clear next steps when errors occur
  • Errors logged with sufficient context for debugging

Testing

  • OAuth flow tested end-to-end in staging
  • Token refresh tested by forcing expiration
  • Error cases tested (invalid tokens, network failures)
  • Token revocation tested (user removes access)
  • Multi-user scenarios tested

Monitoring

  • OAuth failures tracked in monitoring system
  • Token refresh rates monitored
  • Failed authentication attempts logged
  • Alerts configured for unusual OAuth activity

Avoiding These Salesforce OAuth Errors

Salesforce OAuth errors don't have to derail your integration. Most failures stem from the same root causes: inadequate security practices, missing error handling, or misunderstanding how OAuth actually works.

The scenarios we've covered, like token storage, production flows, refresh logic, secret exposure, redirect validation, instance URLs, and error handling, represent the majority of OAuth problems you'll encounter. Fix these seven issues, and your OAuth implementation will be secure, reliable, and maintainable.

Remember: OAuth isn't just authentication. It's a security framework designed to protect user data through delegated access. Treating it as "just get a token and call the API" leads to the errors we've discussed.

Need help implementing secure Salesforce OAuth? Our Salesforce integration services team builds production-ready OAuth implementations that follow security best practices. We'll review your current implementation, identify vulnerabilities, and help you build authentication flows you can trust.

Talk to an Expert