Skip to content

Client Credentials Authentication

Context

Today, SpotifyClient and AsyncSpotifyClient require callers to supply an access_token directly. We want to support Spotify's client credentials flow so the SDK can obtain, cache, and refresh access tokens when given a client ID and client secret.

Client credentials is a server-to-server OAuth flow. It returns an access token with an expires_in TTL, no refresh token, and is intended for non-user data access.

Example:

{
   "access_token": "NgCXRKc...MzYjw",
   "token_type": "bearer",
   "expires_in": 3600
}

Goals

  • Make authentication seamless: users can pass client_id and client_secret and start making API calls without manually obtaining tokens.
  • Keep async-first design: async auth code is the source of truth and sync is generated.
  • Cache and refresh tokens automatically and safely under concurrency.
  • Preserve backward compatibility for users who provide access_token.

Non-goals

  • Implementing Authorization Code, PKCE, or other user-auth flows.
  • Persisting tokens securely to disk (can be added later via a cache interface).
  • Building a full credential manager or secrets vault integration.

Proposed User Experience

Construction API

Preferred: add explicit constructors for client credentials to avoid ambiguity. Auth inputs are mutually exclusive across all entry points.

from spotify_sdk import SpotifyClient, AsyncSpotifyClient

client = SpotifyClient.from_client_credentials(
    client_id="...",
    client_secret="...",
)

async_client = AsyncSpotifyClient.from_client_credentials(
    client_id="...",
    client_secret="...",
)

Alternative (optional): allow client_id + client_secret on the existing constructor, but error if mixed with access_token to keep behavior explicit.

Also allow passing a custom auth provider instance directly to the client constructor for advanced or future flows.

Example:

from spotify_sdk import AsyncSpotifyClient
from spotify_sdk.auth import AsyncClientCredentials

auth = AsyncClientCredentials(
    client_id="...",
    client_secret="...",
)

client = AsyncSpotifyClient(auth_provider=auth)

Environment Variable Support

If client_id or client_secret are omitted, read defaults from:

  • SPOTIFY_SDK_CLIENT_ID
  • SPOTIFY_SDK_CLIENT_SECRET

Precedence: explicit constructor or factory arguments override environment variables. If neither is provided, raise a configuration error. Environment variables are ignored when auth_provider is supplied.

Backward Compatibility

  • access_token remains supported with the current behavior.
  • If multiple auth inputs are provided (access_token, client credentials, auth_provider), raise a configuration error to avoid ambiguous precedence.

Invalid Combinations

Inputs Behavior
access_token + auth_provider Error
access_token + client_id/client_secret Error
auth_provider + client_id/client_secret Error

Auth Architecture

New Auth Module

Add an async-first auth module under src/spotify_sdk/_async/auth/:

  • AsyncAuthProvider: protocol with get_access_token() and optional close().
  • AsyncClientCredentials: fetches access tokens from Spotify Accounts.
  • TokenCache interface: get() / set() with in-memory default.

The sync variant in src/spotify_sdk/_sync/auth/ is generated by unasync.

Provider Protocol

from __future__ import annotations

from typing import Protocol


class AsyncAuthProvider(Protocol):
    async def get_access_token(self) -> str:
        """Return a valid access token (refreshing if needed)."""

    async def close(self) -> None:
        """Optional cleanup hook (default no-op)."""

Sync providers are generated from this protocol and return str without awaiting.

Token Cache Contract

Token cache entries should include:

  • access_token: the token to use for requests.
  • expires_at: absolute UTC timestamp (float seconds since epoch), computed from now + expires_in.

Expiry check should include a small skew window (e.g., 30 seconds) so refresh happens before the token is actually expired. Cache entries are owned by the provider, so additional fields are allowed.

Token Fetching

Client credentials token requests are sent to the Accounts service:

  • POST https://accounts.spotify.com/api/token
  • Authorization: Basic base64(client_id:client_secret)
  • Content-Type: application/x-www-form-urlencoded
  • Body: grant_type=client_credentials

The response includes access_token, token_type (Bearer), and expires_in seconds, but no refresh token.

Base Client Integration

Modify AsyncBaseClient to accept either:

1) A fixed access_token, or 2) An AsyncAuthProvider instance.

Requests should build the Authorization header per request by calling get_access_token() when an auth provider is configured. If a fixed access_token is supplied, reuse it directly and skip the auth provider. The header format is Authorization: Bearer <token>. Per-request overrides are not supported in this design.

Caching and Refresh Strategy

  • Store token and expires_at in a cache entry.
  • Use a small skew window (e.g., 30 seconds) to refresh early.
  • If no token or token is expired/near-expiry, fetch a new token.
  • Because there is no refresh token for this flow, refresh means re-requesting via client credentials.

Concurrency Control

Use an anyio.Lock to ensure only one refresh occurs when multiple tasks attempt to use an expired token concurrently. Other callers should await the lock and then reuse the refreshed token.

For sync clients, use a standard threading.Lock to provide the same behavior.

Error Handling

  • If token acquisition fails, raise AuthenticationError with the response payload for debugging.
  • Optionally, on a 401 response from the API, attempt a single forced refresh and retry once, then surface the error if it persists.

Token endpoint errors should follow the same retry/backoff strategy used by the base client (connection timeouts, 5xx) to keep behavior consistent.

Testing Plan

  • Unit tests for token fetching, cache hit/miss, and refresh behavior.
  • Concurrency test to ensure one refresh for N simultaneous requests.
  • Error-path test for failed token request (invalid client ID/secret).
  • Retry-on-401 test if implemented.
  • Sync variants generated via run_unasync.py.

Documentation Updates (Post-Implementation)

  • Update docs/reference/clients.md to include client credentials usage.
  • Add a quickstart section showing from_client_credentials.
  • Document limitations: client credentials do not access user data endpoints.

Open Questions

  • Do we want constructor kwargs (client_id, client_secret) or only explicit factory methods?
  • Should we expose a public token cache interface now, or keep it internal until persistent caching is requested?
  • What skew window (seconds before expiry) should we use by default?

Future Auth Compatibility

The auth provider interface is intended to support additional flows later (Authorization Code, PKCE, device code) without changing AsyncBaseClient. Those providers can surface scopes and refresh tokens internally while still exposing get_access_token() to the client layer. Provider-specific cache entries can store extra fields (e.g., scopes, refresh tokens) without affecting the base client.

References

  • https://developer.spotify.com/documentation/web-api/concepts/authorization
  • https://developer.spotify.com/documentation/web-api/tutorials/client-credentials-flow
  • https://github.com/spotify/spotify-web-api-ts-sdk/blob/main/src/auth/ClientCredentialsStrategy.ts