Client credentials and service-to-service auth

OAuth 2.0 Course · lesson 12 of 13 · 5 min read

The grant with no user, and the one people misuse most.

Open this lesson in the learning hub

Key points

  • The client credentials grant has no user at all. A service authenticates as itself and receives a token representing the service, not a person.
  • Use it for scheduled jobs, service-to-service calls and anything where there is no human to consent. There is no redirect, no browser and no consent screen.
  • Never use it to act on behalf of a user. A token with no user identity cannot express "this user request" - and using one for that loses all attribution.
  • Scope it narrowly per service. A shared token with broad scope means any compromised service has every permission the fleet has.
  • Cache the token until close to expiry rather than fetching one per call. A token endpoint hit on every request is both slow and a load problem for the identity provider.
  • Where the caller does act for a user, use token exchange: present the user token and receive one scoped to the next service, preserving identity along the chain.

Example

# No user, no browser, no redirect.
$ curl -X POST https://auth.example.com/oauth/token \
    -d grant_type=client_credentials \
    -d client_id=reporting-service \
    -d client_secret=$SECRET \
    -d scope=orders.read

{ "access_token": "eyJ...", "token_type": "Bearer", "expires_in": 3600 }
#   The token subject is the SERVICE. There is no user in it.

---
// Spring: cache it, and let the framework refresh near expiry.
@Bean
OAuth2AuthorizedClientManager clientManager(
        ClientRegistrationRepository registrations,
        OAuth2AuthorizedClientService clients) {

    var provider = OAuth2AuthorizedClientProviderBuilder.builder()
            .clientCredentials()
            .build();

    var manager = new AuthorizedClientServiceOAuth2AuthorizedClientManager(
            registrations, clients);
    manager.setAuthorizedClientProvider(provider);
    return manager;   // caches the token; refreshes when it is near expiry
}

@Bean
RestClient apiClient(OAuth2AuthorizedClientManager manager) {
    var interceptor = new OAuth2ClientHttpRequestInterceptor(manager);
    interceptor.setClientRegistrationIdResolver(r -> "orders-api");
    return RestClient.builder()
            .baseUrl("https://orders.internal")
            .requestInterceptor(interceptor)
            .build();
}

/*
 * CHOOSING THE GRANT:
 *
 *   a user is present, browser        authorization_code + PKCE
 *   a user is present, no browser     device_code (TVs, CLIs)
 *   NO user - a job or a service      client_credentials
 *   acting FOR a user, downstream     token exchange, not client_credentials
 *
 *   password grant                    DEPRECATED - do not use it
 *   implicit grant                    DEPRECATED - superseded by PKCE
 */

Client credentials identifies a service and cannot represent a user - reach for token exchange when identity must travel downstream.

This is a reading copy. The full lesson — with the visual explainer, the interactive lab and a Run button for the code — lives in the OAuth 2.0 Course course, and every lesson in it is listed on the OAuth 2.0 Course contents page.