Skip to main content

How to Handle OAuth Token Refresh Across Multiple Music Streaming APIs

Published on July 11, 2026

How to Handle OAuth Token Refresh Across Multiple Music Streaming APIs

Each streaming service implements OAuth 2.0 differently. Token lifetimes range from 1 hour to 1 year, refresh flows vary, and error responses are inconsistent. Managing this across 10+ services multiplies the complexity. This post breaks down the differences, shows you how to build a token refresh layer, and covers the common failure modes that will bite you in production.

Why OAuth Gets Complicated with Multiple Music Services

OAuth 2.0 is a standard, but "standard" does not mean "identical." Every music streaming service interprets the spec with its own quirks: different token lifetimes, different refresh behaviors, different error codes when things go wrong.

Here is what you are actually dealing with when you integrate multiple services:

ServiceAccess Token LifetimeRefresh Token BehaviorToken RotationScope Format
Service A1 hourRefresh token returned on initial authNo rotationspace-separated
Service B1 hourRefresh token with rotation on each useYes, old token invalidatedspace-separated
Service CVaries by grant typeLong-lived tokens, no refresh flowN/Acustom format
Service D1 hourStandard refresh flowOptional rotationspace-separated
Service E1 hourRefresh token with extended lifetimeNo rotationcomma-separated
Service F12 hoursNon-standard refresh endpointNo rotationspace-separated

That is six different behaviors you need to handle, and this table only covers token lifetimes. Scope naming, error formats, and revocation flows add more variation on top.

The real cost is not writing six OAuth implementations. It is maintaining them. Services change their auth behavior without warning. A token lifetime that was 1 hour becomes 30 minutes. A refresh endpoint that returned JSON starts returning form-encoded data. Your token refresh layer needs to absorb these changes without breaking your application.

The Standard OAuth 2.0 Flow for Music APIs

Before covering where services diverge, here is the baseline flow that most music APIs follow. If you have implemented OAuth before, this will look familiar.

Step 1: Authorization Request. Your app redirects the user to the service's authorization endpoint with your client ID, requested scopes, and a redirect URI.

Step 2: User Grants Access. The user logs in and approves the requested permissions.

Step 3: Authorization Code. The service redirects back to your app with a one-time authorization code.

Step 4: Token Exchange. Your server exchanges the authorization code for an access token and (usually) a refresh token.

Step 5: API Calls. You use the access token in the Authorization header for every API request.

Step 6: Token Refresh. When the access token expires, you use the refresh token to get a new one without requiring the user to log in again.

// Standard token exchange (Step 4)
const tokenResponse = await fetch('https://service.example/oauth/token', {
  method: 'POST',
  headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
  body: new URLSearchParams({
    grant_type: 'authorization_code',
    code: authorizationCode,
    redirect_uri: 'https://yourapp.com/callback',
    client_id: CLIENT_ID,
    client_secret: CLIENT_SECRET,
  }),
});

const { access_token, refresh_token, expires_in } = await tokenResponse.json();

Simple enough for one service. The problems start when you multiply this by six, eight, or twelve services.

Where Each Service Diverges

Token Lifetime Differences

Access token lifetimes vary by orders of magnitude. One service gives you 3,600 seconds (1 hour). Another gives you tokens that last a full year. Some services do not even return an expires_in field, leaving you to discover expiration through a 401 response.

This means your token storage layer cannot use a single TTL. You need per-service expiration tracking, and you need to handle the case where no expiration time is provided at all.

function getTokenExpiry(tokenResponse, service) {
  if (tokenResponse.expires_in) {
    return Date.now() + (tokenResponse.expires_in * 1000);
  }
  // Fallback: assume 1 hour if the service doesn't tell us
  const defaultExpiry = {
    'service_a': 3600,
    'service_b': 3600,
    'service_c': 31536000, // 1 year
    'service_d': 3600,
  };
  return Date.now() + ((defaultExpiry[service] || 3600) * 1000);
}

Refresh Token Rotation Policies

Some services issue a new refresh token every time you use the old one. The old refresh token becomes invalid immediately. If your code retries a failed refresh with the same token, you will lock the user out permanently.

Other services reuse the same refresh token indefinitely. Your storage layer needs to handle both: always store the latest refresh token from the response, and never retry a refresh call with a token you have already used.

async function refreshAccessToken(service, refreshToken) {
  const response = await fetch(getTokenEndpoint(service), {
    method: 'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body: new URLSearchParams({
      grant_type: 'refresh_token',
      refresh_token: refreshToken,
      client_id: getClientId(service),
      client_secret: getClientSecret(service),
    }),
  });

  const data = await response.json();

  // Always store the new refresh token if one is returned
  // Some services rotate; others return the same one
  const newRefreshToken = data.refresh_token || refreshToken;

  return {
    accessToken: data.access_token,
    refreshToken: newRefreshToken,
    expiresAt: getTokenExpiry(data, service),
  };
}

Scope Naming Inconsistencies

One service uses user-library-read. Another uses read:user:library. A third uses a completely custom permission string. When your app requests the same logical permission (read the user's saved tracks) from different services, you need a scope mapping layer that translates your internal permission names into service-specific scope strings.

Error Response Formats

When a token refresh fails, the error format varies wildly:

// Service A: Standard OAuth error
{ "error": "invalid_grant", "error_description": "The refresh token is invalid." }

// Service B: Custom error envelope
{ "status": 401, "message": "Token expired or revoked" }

// Service C: HTML error page (yes, really)
"<html><body>Unauthorized</body></html>"

Your error handling code needs to normalize these responses into a consistent internal format before your application logic can decide what to do next.

Building a Token Refresh Layer That Handles All Services

The naive approach is a switch statement with per-service refresh logic. It works until you hit your fifth service, and then maintenance becomes a full-time job.

A better approach: build a configuration-driven refresh layer where each service's quirks are captured in a config object, and the refresh logic is generic.

const serviceConfigs = {
  service_a: {
    tokenEndpoint: 'https://accounts.service-a.com/api/token',
    authMethod: 'body', // client_secret in POST body
    rotatesRefreshToken: false,
    defaultExpiry: 3600,
    errorParser: parseStandardOAuthError,
  },
  service_b: {
    tokenEndpoint: 'https://auth.service-b.com/v1/token',
    authMethod: 'header', // Basic auth header
    rotatesRefreshToken: true,
    defaultExpiry: 3600,
    errorParser: parseCustomEnvelopeError,
  },
  service_c: {
    tokenEndpoint: 'https://service-c.com/oauth/access_token',
    authMethod: 'body',
    rotatesRefreshToken: false,
    defaultExpiry: 31536000,
    errorParser: parsePlaintextError,
  },
};

async function refreshToken(service, currentRefreshToken) {
  const config = serviceConfigs[service];
  if (!config) throw new Error(`Unknown service: ${service}`);

  const headers = { 'Content-Type': 'application/x-www-form-urlencoded' };
  const body = {
    grant_type: 'refresh_token',
    refresh_token: currentRefreshToken,
  };

  if (config.authMethod === 'header') {
    const credentials = Buffer.from(`${getClientId(service)}:${getClientSecret(service)}`).toString('base64');
    headers['Authorization'] = `Basic ${credentials}`;
  } else {
    body.client_id = getClientId(service);
    body.client_secret = getClientSecret(service);
  }

  const response = await fetch(config.tokenEndpoint, {
    method: 'POST',
    headers,
    body: new URLSearchParams(body),
  });

  if (!response.ok) {
    const errorData = await response.text();
    throw config.errorParser(errorData, response.status);
  }

  const data = await response.json();
  return {
    accessToken: data.access_token,
    refreshToken: config.rotatesRefreshToken
      ? (data.refresh_token || currentRefreshToken)
      : currentRefreshToken,
    expiresAt: Date.now() + ((data.expires_in || config.defaultExpiry) * 1000),
  };
}

This pattern scales. Adding a new service means adding a config object, not writing new refresh logic.

But here is the thing: you still need to build this for every service, test it against real OAuth endpoints, handle edge cases when services change their behavior, and maintain it over time. MusicAPI handles all of this for you. One authentication callback, zero refresh logic in your codebase, and token management for all 12 supported services runs automatically behind the scenes.

Common Auth Failures and How to Recover

Expired Refresh Tokens

Some services expire refresh tokens after a period of inactivity (typically 30 to 90 days). When this happens, the refresh call returns invalid_grant and the user must re-authorize your app from scratch.

Your app needs to detect this and trigger a re-auth flow gracefully. Do not show a generic error. Tell the user their connection to the service has expired and give them a one-click path to reconnect.

Revoked Access

Users can revoke your app's access from their streaming service settings at any time. The next API call or token refresh will fail. Your app should handle this the same way it handles expired refresh tokens: detect the revocation and prompt re-authorization.

Rate-Limited Auth Endpoints

Auth endpoints have rate limits too, and they are often stricter than data endpoints. If your app refreshes tokens too aggressively (refreshing on every API call instead of caching the access token), you will hit rate limits on the auth server before you hit limits on the data API.

Cache access tokens with their expiration time and only refresh when the token is expired or within a short buffer window (30 to 60 seconds before expiry). MusicAPI's built-in rate limiting handles this automatically across all services.

Requesting Original Auth Tokens When You Need Direct Service Access

Sometimes your app needs the raw access token for a specific service, not the unified token. Maybe you are calling a service-specific endpoint that MusicAPI does not cover, or you need to make a direct WebSocket connection.

MusicAPI lets you request the original auth tokens for any connected service. You get the raw access token, its expiration, and the scopes it was granted, all through a single API call.

// Get the original service token through MusicAPI
const tokenResponse = await fetch(
  `https://api.musicapi.com/api/${userUUID}/auth/token`,
  {
    headers: {
      'Authorization': 'Bearer YOUR_MUSICAPI_TOKEN',
      'x-service': 'spotify',
    },
  }
);

const { access_token, expires_in, scopes } = await tokenResponse.json();
// Use access_token directly with the service's own API

This gives you the best of both worlds: MusicAPI handles the OAuth lifecycle (authorization, token storage, refresh, rotation), and you can still drop down to direct service access when needed.

FAQ

How often do music API OAuth tokens expire?

It depends on the service. Most services issue access tokens that expire in 1 hour. Some issue tokens lasting 12 hours or longer. A few services issue long-lived tokens that last months or even a year. Your app needs per-service expiration tracking because there is no single answer.

Can I use one OAuth flow for multiple music services?

Not natively. Each service has its own authorization endpoint, client credentials, and scope format. You need separate OAuth integrations for each service. MusicAPI solves this with a single unified authentication flow that handles all supported services through one callback URL.

What happens when a user revokes access to one service?

Your next API call or token refresh for that service will fail with an authorization error. Your app should catch this, mark the service connection as inactive, and prompt the user to re-authorize. Other connected services remain unaffected.

Do I need to store tokens myself if I use a unified music API?

No. MusicAPI stores and manages all OAuth tokens (access tokens, refresh tokens, expiration times) on your behalf. Your app stores only the MusicAPI user UUID. When a token needs refreshing, MusicAPI handles it automatically before completing your API request.

How do I handle OAuth for services that do not support refresh tokens?

Some services use long-lived access tokens instead of the refresh token flow. Others require the user to re-authorize when the token expires. Your token management layer needs to know which pattern each service uses and trigger the right recovery flow. Check the supported services documentation for details on each service's auth behavior.

What is PKCE and do music APIs require it?

PKCE (Proof Key for Code Exchange) is an OAuth 2.0 extension that protects the authorization code flow against interception attacks. It replaces the client secret with a dynamically generated code verifier and challenge. Some music services require PKCE for public clients (mobile and single-page apps). Others still accept the standard authorization code flow with a client secret. MusicAPI's authorization flow handles PKCE requirements per service automatically.

How do I prevent token refresh race conditions?

When multiple requests hit your server at the same time with an expired token, they can all try to refresh simultaneously. If the service rotates refresh tokens, only the first refresh succeeds and the rest fail with invalid_grant. Use a mutex or queue to ensure only one refresh happens at a time per user per service, and have other requests wait for the result.


Ready to skip months of OAuth and SDK work? Start your free MusicAPI trial and connect 10+ streaming services with one unified API.