OAuth client credentials flow: machine-to-machine auth without users

The OAuth client credentials grant is the simplest OAuth flow: a client authenticates directly with the authorization server using its client ID and secret, and receives an access token. There is no user interaction, no authorization code exchange, no redirect. It is the standard mechanism for service-to-service authentication — background jobs calling APIs, microservices calling each other, CI/CD pipelines accessing deployment APIs. Despite its simplicity, the implementation details around scope design, token caching, and secret rotation matter significantly at scale.

The token request

// Client credentials token request
POST /oauth/token HTTP/1.1
Host: auth.example.com
Content-Type: application/x-www-form-urlencoded
Authorization: Basic base64(client_id:client_secret)

grant_type=client_credentials&scope=read:reports+write:queue

// Response
{
  "access_token": "eyJhbGciOiJSUzI1NiIsImtpZCI6...",
  "token_type": "Bearer",
  "expires_in": 3600,
  "scope": "read:reports write:queue"
}

Unlike user-delegated flows, there is no refresh token. When the access token expires, the client simply requests a new one. This is appropriate because the client has its credentials available at all times — there is no sleeping user who needs to re-authenticate to allow a new token to be issued.

Scope design for service accounts

M2M clients should follow the principle of least privilege: request only the scopes needed for their specific function. A reporting service that reads aggregated data should have read:reports, not admin:*. This limits the blast radius if the client secret is compromised.

Design scopes with M2M use cases in mind. For each background service in your architecture, define the minimum set of scopes it needs. Register a separate OAuth client per service rather than sharing a single M2M client across services — this lets you revoke individual services without affecting others, and provides per-service audit trails.

// Authorization server: client credentials token issuance
app.post('/oauth/token', async (req, res) => {
  if (req.body.grant_type !== 'client_credentials') return next();

  const { client_id, client_secret } = extractClientCredentials(req);
  const client = await validateClient(client_id, client_secret);

  if (!client) {
    return res.status(401).json({ error: 'invalid_client' });
  }

  const requestedScopes = (req.body.scope || '').split(' ').filter(Boolean);
  const allowedScopes = client.allowed_scopes;

  // Only grant scopes the client is configured for
  const grantedScopes = requestedScopes.length > 0
    ? requestedScopes.filter(s => allowedScopes.includes(s))
    : allowedScopes;

  if (grantedScopes.length === 0) {
    return res.status(400).json({ error: 'invalid_scope' });
  }

  const token = await issueClientCredentialsToken({
    clientId: client.id,
    scopes: grantedScopes,
    expiresIn: 3600
  });

  res.json({
    access_token: token,
    token_type: 'Bearer',
    expires_in: 3600,
    scope: grantedScopes.join(' ')
  });
});

Token caching

Without caching, every API call from a service requires a token request to the authorization server first. At high call volumes, this adds unnecessary latency and load to your auth server. The correct pattern is to cache the token for most of its lifetime and request a new one when it is close to expiry.

// Token manager with caching
class M2MTokenManager {
  private tokenCache = new Map<string, { token: string; expiresAt: number }>();
  private refreshThresholdSeconds = 60;  // refresh 60s before expiry

  async getToken(
    clientId: string,
    clientSecret: string,
    scope: string
  ): Promise<string> {
    const cacheKey = `${clientId}:${scope}`;
    const cached = this.tokenCache.get(cacheKey);

    if (cached && cached.expiresAt > Date.now() / 1000 + this.refreshThresholdSeconds) {
      return cached.token;
    }

    // Fetch new token
    const response = await fetch('https://auth.example.com/oauth/token', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/x-www-form-urlencoded',
        'Authorization': 'Basic ' + Buffer.from(`${clientId}:${clientSecret}`).toString('base64')
      },
      body: new URLSearchParams({
        grant_type: 'client_credentials',
        scope
      })
    });

    if (!response.ok) {
      throw new Error(`Token request failed: ${response.status}`);
    }

    const data = await response.json();
    const expiresAt = Math.floor(Date.now() / 1000) + data.expires_in;

    this.tokenCache.set(cacheKey, {
      token: data.access_token,
      expiresAt
    });

    return data.access_token;
  }
}
In distributed environments, use a shared cache (Redis) rather than per-process in-memory caching. If you have 10 instances of a service, each refreshing its own token independently, you will have 10 times the token requests. A shared cache means the first instance to need a refresh does it, and all others use the cached result.

Secret rotation strategies

Client secrets for M2M clients need to be rotatable without service downtime. The challenge is that rotating a secret requires updating it in the service's configuration, which may require a deployment. During the deployment, there is a window where the old secret is still in use. A rotation approach that avoids downtime:

  1. Issue a second client secret (most OAuth servers support multiple active secrets per client).
  2. Deploy the service with the new secret. The old secret is still valid, so in-flight requests continue to work.
  3. Verify the service is using the new secret and all instances have rotated.
  4. Revoke the old secret.
// Authorization server: support multiple active secrets
interface OAuthClient {
  id: string;
  secrets: {
    id: string;
    hash: string;
    created_at: Date;
    expires_at?: Date;
    revoked: boolean;
  }[];
}

async function validateClientSecret(
  clientId: string,
  secret: string
): Promise<boolean> {
  const client = await db.oauthClients.findById(clientId);
  if (!client) return false;

  const activeSecrets = client.secrets.filter(s => {
    if (s.revoked) return false;
    if (s.expires_at && new Date() > s.expires_at) return false;
    return true;
  });

  for (const stored of activeSecrets) {
    const valid = await argon2.verify(stored.hash, secret);
    if (valid) {
      // Update last_used_at for this specific secret
      await db.clientSecrets.update(stored.id, { last_used_at: new Date() });
      return true;
    }
  }

  return false;
}

For high-security environments, consider private_key_jwt client authentication instead of client secrets. The client generates an asymmetric key pair, registers the public key, and authenticates by signing a JWT with the private key. This eliminates the secret storage problem entirely — the private key can be stored in a hardware security module and never transmitted, and rotation requires only a key generation step with no window of secret exposure.

← Back to blog Try Bastionary free →