Skip to main content

Solving Salesforce REST API 'INVALID_SESSION_ID' Errors in Enterprise Apps

 Data synchronization failures in an Enterprise CRM integration often trace back to a single, abrupt authentication failure. You monitor your background workers or integration middleware, only to find HTTP 401 Unauthorized responses containing a specific payload: [{"message":"Session expired or invalid","errorCode":"INVALID_SESSION_ID"}].

This error halts critical business processes, from lead routing to financial data synchronization. Resolving it requires more than simply requesting a new token on startup; it demands a resilient authentication architecture capable of seamless, concurrent token refreshment.

Why the INVALID_SESSION_ID Error Occurs

The Salesforce REST API auth mechanism relies on standard OAuth 2.0 protocols. When a client application authenticates, Salesforce issues an Access Token (often referred to as a Session ID in standard API contexts) and, depending on the flow, a Refresh Token.

The INVALID_SESSION_ID error is triggered when the provided Access Token is rejected by the Salesforce resource server. In enterprise environments, this happens for three primary reasons:

  1. Standard Session Timeout: Salesforce organizations enforce global session timeout policies (e.g., 2 hours). If your integration idles beyond this window, the token naturally expires.
  2. Salesforce Connected App Policy Enforcement: Administrators can configure the Salesforce connected app to enforce strict IP relaxation policies or revoke sessions if the user's source IP changes mid-session.
  3. Manual or Automated Revocation: A system administrator may manually revoke OAuth tokens via the user interface, or a password reset on the integration user account may invalidate active sessions.

Failing to handle these expiration events gracefully results in dropped requests and data inconsistency.

The Solution: Axios Interceptors with Concurrency Management

To build a fault-tolerant integration, the application must intercept the INVALID_SESSION_ID error, pause outbound API requests, silently negotiate a new token, and retry the failed requests.

If your application makes concurrent requests, a naive retry mechanism will trigger a "refresh storm," sending multiple refresh requests to Salesforce simultaneously. This can result in rate limiting or token invalidation. The solution is an HTTP interceptor with a concurrency queue.

Implementation in TypeScript

The following TypeScript implementation uses axios to manage Salesforce REST API requests. It queues concurrent requests while a single refresh operation occurs, ensuring optimal performance and compliance with Salesforce limits.

import axios, { AxiosInstance, AxiosError, InternalAxiosRequestConfig } from 'axios';

// Configuration interface for Salesforce credentials
interface SalesforceConfig {
  clientId: string;
  clientSecret: string;
  refreshToken: string;
  instanceUrl: string; // e.g., https://your-domain.my.salesforce.com
}

// Queue mechanism for concurrent requests during token refresh
interface PendingRequest {
  resolve: (value: unknown) => void;
  reject: (reason?: any) => void;
}

class SalesforceApiClient {
  private api: AxiosInstance;
  private isRefreshing: boolean = false;
  private failedQueue: PendingRequest[] = [];
  private accessToken: string | null = null;

  constructor(private config: SalesforceConfig) {
    this.api = axios.create({
      baseURL: `${this.config.instanceUrl}/services/data/v60.0`,
      headers: {
        'Content-Type': 'application/json',
      },
    });

    this.initializeRequestInterceptor();
    this.initializeResponseInterceptor();
  }

  private initializeRequestInterceptor(): void {
    this.api.interceptors.request.use(
      (reqConfig: InternalAxiosRequestConfig) => {
        if (this.accessToken) {
          reqConfig.headers.Authorization = `Bearer ${this.accessToken}`;
        }
        return reqConfig;
      },
      (error: AxiosError) => Promise.reject(error)
    );
  }

  private initializeResponseInterceptor(): void {
    this.api.interceptors.response.use(
      (response) => response,
      async (error: AxiosError) => {
        const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean };

        // Identify the specific Salesforce INVALID_SESSION_ID error
        const isAuthError = error.response?.status === 401;
        const responseData = error.response?.data as Array<{ errorCode: string }>;
        const isInvalidSession = Array.isArray(responseData) && responseData[0]?.errorCode === 'INVALID_SESSION_ID';

        if (isAuthError && isInvalidSession && originalRequest && !originalRequest._retry) {
          if (this.isRefreshing) {
            // If already refreshing, queue the request
            return new Promise((resolve, reject) => {
              this.failedQueue.push({ resolve, reject });
            })
              .then((token) => {
                originalRequest.headers.Authorization = `Bearer ${token}`;
                return this.api(originalRequest);
              })
              .catch((err) => Promise.reject(err));
          }

          originalRequest._retry = true;
          this.isRefreshing = true;

          try {
            const newToken = await this.refreshAccessToken();
            this.accessToken = newToken;
            
            // Process queued requests with the new token
            this.processQueue(null, newToken);
            
            // Retry the original request
            originalRequest.headers.Authorization = `Bearer ${newToken}`;
            return await this.api(originalRequest);
          } catch (refreshError) {
            this.processQueue(refreshError as Error, null);
            return Promise.reject(refreshError);
          } finally {
            this.isRefreshing = false;
          }
        }

        return Promise.reject(error);
      }
    );
  }

  private async refreshAccessToken(): Promise<string> {
    const tokenEndpoint = `${this.config.instanceUrl}/services/oauth2/token`;
    const params = new URLSearchParams({
      grant_type: 'refresh_token',
      client_id: this.config.clientId,
      client_secret: this.config.clientSecret,
      refresh_token: this.config.refreshToken,
    });

    const response = await axios.post(tokenEndpoint, params.toString(), {
      headers: {
        'Content-Type': 'application/x-www-form-urlencoded',
      },
    });

    return response.data.access_token;
  }

  private processQueue(error: Error | null, token: string | null = null): void {
    this.failedQueue.forEach((prom) => {
      if (error) {
        prom.reject(error);
      } else {
        prom.resolve(token);
      }
    });
    this.failedQueue = [];
  }

  // Expose the API instance for application use
  public getClient(): AxiosInstance {
    return this.api;
  }
}

// Usage Example
export const salesforceClient = new SalesforceApiClient({
  clientId: process.env.SF_CLIENT_ID!,
  clientSecret: process.env.SF_CLIENT_SECRET!,
  refreshToken: process.env.SF_REFRESH_TOKEN!,
  instanceUrl: process.env.SF_INSTANCE_URL!,
}).getClient();

Deep Dive: How the Architecture Works

The architecture relies on stateful HTTP interception to isolate the application layer from authentication volatility.

When an API call responds with INVALID_SESSION_ID, the interceptor traps the error before it bubbles up to your business logic. The originalRequest._retry = true flag prevents infinite loops in cases where the refresh token itself is invalid.

The isRefreshing boolean acts as a mutex lock. If a second asynchronous request fails with a 401 while the lock is active, it does not attempt to hit the /services/oauth2/token endpoint. Instead, it generates an unresolved Promise and stores the resolve and reject functions in the failedQueue.

Once the refreshAccessToken() method successfully returns a new Bearer token, processQueue() iterates through the suspended requests, resolving their promises with the new token. The interceptor then applies the new authorization header and re-executes the original HTTP calls. From the perspective of the application layer, the request simply took slightly longer to resolve.

Common Pitfalls and Edge Cases

Implementing the code above handles the networking layer, but enterprise architectures must account for Salesforce-specific configuration issues that can break this flow.

Missing offline_access Scope

If you are generating your initial OAuth tokens using the Web Server Flow, you must explicitly request the refresh_token, offline_access scope. Without this, Salesforce will not return a refresh token, rendering the automated recovery mechanism useless.

Connected App Refresh Token Policy

In the Salesforce Setup menu under Manage Connected Apps, administrators define the "Refresh Token Policy". By default, it may be set to expire refresh tokens after a period of inactivity. For server-to-server integrations, this should generally be set to Refresh token is valid until revoked. If a refresh token expires, the refreshAccessToken() call will fail with a 400 Bad Request (invalid_grant), requiring human intervention to re-authenticate.

OAuth 2.0 JWT Bearer Flow Alternative

If your enterprise security policies strictly prohibit long-lived refresh tokens, you should abandon the refresh token flow entirely. Instead, implement the OAuth 2.0 JWT Bearer Flow. In this pattern, you use an X.509 certificate to sign a JWT, which is exchanged directly for an Access Token. You would modify the refreshAccessToken() method in the code above to sign and transmit a JWT rather than posting a refresh_token.

Final Architectural Considerations

Handling the Salesforce INVALID_SESSION_ID error robustly is a prerequisite for any stable enterprise CRM integration. By shifting the responsibility of session management from the application's business logic down to the HTTP client layer, you ensure that network volatility and token expirations are resolved predictably, preventing data loss and minimizing integration downtime.