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
- Your application requests authorization from Salesforce
- User grants permission through Salesforce's login screen
- Salesforce returns an authorization code
- Your application exchanges this code for an access token
- You use the access token to authenticate API requests
- 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:
const data = await response.json();
this.accessToken = data.access_token;
// Not saving new refresh token - next refresh will fail
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:
// NEVER DO THIS
const saveToken = async (userId, refreshToken) => {
await database.query(
'INSERT INTO tokens (user_id, refresh_token) VALUES (?, ?)',
[userId, refreshToken]
);
};
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.
// 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 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.
// 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();
}
}
// 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.
// 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();
};
// 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:
// 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.
// 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);
});
// 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:
- Set "Callback URL" to exact, complete URLs (not wildcards)
- Add only URLs you control
- Use HTTPS for production URLs
- Don't use localhost URLs in production Connected Apps
- 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.
// 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();
};
// 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.
// 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?
};
// 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