Skip to content

Machine-to-Machine Access with the Client Credentials Grant

Introduction

A nightly job reads orders from an API. A billing service asks a pricing service for a quote. No user is involved in either call, so there is nobody to send to a login page. The calling service proves who it is with its own credentials and gets an access token for itself. That is the client credentials grant of RFC 6749, section 4.4.

This guide sets it up end to end with two projects from the Getting Started repository:

  • OpenIDProviderApp.MinimalApi, the provider, on https://localhost:5006. It already registers a client for this grant, console_client.
  • ApiSample, the protected API, on https://localhost:5004. It accepts a request only when its access token carries the weather scope.

By the end, a token request returns a token, and the API answers 200 to a call carrying it. Along the way you will also see three near-miss requests and the refusal each one gets.

Register the API as a resource

The provider has to know about the API before it can issue a token for it. Declare it as a resource: its address and the scopes it accepts. The sample does this in Program.cs:

C#
builder.Services.AddOidcServices(options =>
{
    options.Resources =
    [
        new ResourceDefinition(new Uri("https://localhost:5004", UriKind.Absolute), new ScopeDefinition("weather")),
    ];
});

The address becomes the audience (aud) of every token issued for this API, and the API checks that value, so it has to match exactly what the API expects. The weather scope belongs to this resource, which matters when the token is requested: see Name the resource in the request.

Register the calling service as a client

The sample already registers console_client in the Oidc section of appsettings.json, which the provider binds into its options. A registration for a real service has the same shape and adds AllowedScopes:

JSON
{
  "ClientId": "console_client",
  "ClientSecrets": [ { "Sha512Hash": "vSsar3708Jvp9Szi2NWZZ02Bqp1qRCFpbcTZPdBhnWgs5WtNZKnvCXdhztmeD2cmW192CF5bDufKRpayrW/isg==" } ],
  "TokenEndpointAuthMethod": "client_secret_post",
  "AllowedGrantTypes": [ "client_credentials" ],
  "AllowedScopes": [ "weather" ]
}

What each setting controls:

  • ClientSecrets holds the SHA-512 hash of the client secret, base64-encoded, never the secret itself. The hash above is the sample's test secret, secret, and it is published in a public repository, so never deploy it. Generate a random secret with openssl rand -base64 32 and hash it with printf %s "$SECRET" | openssl dgst -sha512 -binary | openssl base64 -A. A single SHA-512 hash is safe only for a random secret like this one, never for a password. The calling service keeps the secret itself in a secret store rather than in its appsettings.json.
  • ClientSecrets is a list so that a secret can be rotated without downtime: add the new hash next to the old one (each entry can carry an ExpiresAt), move the service to the new secret, then remove the old entry.
  • TokenEndpointAuthMethod is enforced. A client registered with client_secret_post sends its credentials in the form body; the same credentials sent in an HTTP Basic header are refused with invalid_client. The library's default is client_secret_basic, the method RFC 6749, section 2.3.1 prefers and most client libraries send. The sample registers client_secret_post only so that the curl call below stays readable; for a real service, register client_secret_basic or leave the setting out.
  • AllowedGrantTypes limits the client to this grant. A service has no use for the authorization code grant, so it should not have it.
  • AllowedScopes limits which scopes the client may ask for. Leave it out and the client may ask for any scope the provider knows; the sample's console_client leaves it out. It is also the only per-client boundary between your APIs: the provider issues a token for any registered resource a client names, so a client without AllowedScopes can reach every scope of every API this provider protects.

Request a token

The service posts to the token endpoint, which the provider names in its discovery document (/.well-known/openid-configuration). You will run this call in Run it, once both projects are started; for now, read what it sends:

Bash
curl -X POST https://localhost:5006/connect/token \
  -d grant_type=client_credentials \
  -d client_id=console_client \
  -d client_secret=secret \
  -d scope=weather \
  -d resource=https://localhost:5004

The response carries the token and how long it lasts:

JSON
{
  "access_token": "eyJ0eXAiOiJhdCtqd3Qi...",
  "issued_token_type": "urn:ietf:params:oauth:token-type:access_token",
  "token_type": "Bearer",
  "expires_in": 600,
  "refresh_token": null,
  "scope": "weather",
  "id_token": null,
  "authorization_details": null
}

The token is a JWT of type at+jwt, the JWT profile for access tokens of RFC 9068. Decode its payload and you will find aud set to https://localhost:5004, scope set to weather, and both sub and client_id set to console_client, because the service is acting for itself. No refresh token comes with it: when the token expires, the service asks for a new one the same way.

Its lifetime comes from the client's AccessTokenExpiresIn, ten minutes unless you set it. That lifetime is also how long a leaked token stays usable, because a self-contained token cannot be recalled: disabling the client or rotating its secret does not stop a token already issued. The API adds the JwtBearer handler's default clock skew of five minutes on top, so a ten-minute token can be accepted for up to fifteen. Keep AccessTokenExpiresIn short, and lower TokenValidationParameters.ClockSkew in the API if that window matters.

The calling service should not request a new token for every call. Cache it per scope and resource, refresh it about a minute before expires_in runs out, and on a 401 from the API fetch a new one once and retry.

Name the resource in the request

The resource parameter comes from Resource Indicators for OAuth 2.0 (RFC 8707). It tells the provider which API the token is for. Abblix OIDC Server also makes a resource's scopes available only to a request that names the resource; other providers may infer the audience from the scope instead. Each of these requests looks almost right and fails in its own way:

  • Scope without the resource. The provider answers 400 with invalid_scope and "The scope is not available": weather belongs to the resource, and a request that names no resource has no access to the resource's scopes.
  • The resource without the scope. The token is issued for https://localhost:5004 with an empty scope, and the API answers 403: the caller is authenticated, but its token grants nothing the endpoint requires. That empty-scope token is still valid, with the right audience, for any client of this provider, so an API that only requires an authenticated caller lets every one of them in. Every endpoint has to require a scope as well.
  • Neither. The token's audience falls back to the provider's own issuer, and the API answers 401 with invalid_token, because the audience is not the one it expects. A provider with a single API can set OidcOptions.DefaultResourceIndicator to that API's address; a request that names no resource then gets it as its audience, though a resource scope still needs the resource to be named.

Validate the token in the API

ApiSample validates tokens with the standard JwtBearer handler, configured in appsettings.Development.json:

JSON
"JwtBearerAuthentication": {
  "Authority": "https://localhost:5001",
  "MapInboundClaims": false,
  "TokenValidationParameters": {
    "ValidTypes": [ "at+jwt" ],
    "ValidAudience": "https://localhost:5004",
    "ValidIssuer": "https://localhost:5001"
  }
}
  • Authority is where the handler fetches the provider's discovery document and signing keys. The file points it at the MVC provider on port 5001; Run it points it at the Minimal API provider instead.
  • ValidIssuer must equal the token's iss, the provider's issuer. Like Authority, it names port 5001 in the file and is moved to 5006 in Run it.
  • ValidTypes accepts access tokens only. An ID token signed by the same provider is refused, even though its signature is valid.
  • ValidAudience is the resource address registered above. This check is what stops a token issued for another API from being accepted here.
  • MapInboundClaims set to false keeps the claim names as the token carries them, so the scope is read from a claim called scope.

The scope check is an authorization policy. The scope claim is a single string of space-separated values, so the policy splits it before looking for weather:

C#
builder.Services.AddAuthorization(options => options.AddPolicy("WeatherApi", policy =>
{
    policy.RequireAuthenticatedUser();
    policy.RequireAssertion(context =>
    {
        var scope = context.User.FindFirstValue("scope")?.Split(' ', StringSplitOptions.RemoveEmptyEntries);
        return scope is not null && scope.Contains("weather", StringComparer.Ordinal);
    });
}));

The sample applies this policy to /weatherforecast with RequireAuthorization("WeatherApi"). A check of the whole string for equality with weather works until the day a client asks for two scopes, and then refuses a token that should pass.

Run it

Both projects listen on HTTPS with the ASP.NET Core development certificate, so trust it once with dotnet dev-certs https --trust, or curl refuses the connection. Clone the Getting Started repository and, from its root, start the provider:

Bash
dotnet run --project OpenIDProviderApp.MinimalApi

In a second terminal, start the API with its authority and issuer pointed at that provider. Environment variables override appsettings.Development.json, so no file needs editing:

Bash
JwtBearerAuthentication__Authority=https://localhost:5006 \
JwtBearerAuthentication__TokenValidationParameters__ValidIssuer=https://localhost:5006 \
dotnet run --project ApiSample

In PowerShell, set both variables and then start the API:

Code
$env:JwtBearerAuthentication__Authority = "https://localhost:5006"
$env:JwtBearerAuthentication__TokenValidationParameters__ValidIssuer = "https://localhost:5006"
dotnet run --project ApiSample

Request a token with the curl call from Request a token, then call the API with it:

Bash
curl https://localhost:5004/weatherforecast -H "Authorization: Bearer <access_token>"

The API answers 200 with a JSON forecast. The same call without the header answers 401. In PowerShell, run these calls as curl.exe with all the arguments on one line, since the backslash line breaks above are bash syntax.

Troubleshooting

The API answers 401 to a fresh token

Decode the token and check aud. If it is the provider's address rather than https://localhost:5004, the request left out resource=https://localhost:5004. If no token reached the API at all, check the Authorization: Bearer header.

The API answers 401 after the provider was restarted

The sample provider generates a new signing key every time it starts, so tokens issued after a restart carry a key the API has not cached. The API fetches the provider's key set again after such a failure, but no more than once every five minutes, so the first call fails and later ones pass; after several restarts in a row, it can take up to five minutes. Restarting the API is the quickest way back. In production, the provider publishes a new key before it signs with it and keeps the old one published until the tokens it signed have expired, which is one reason to keep signing keys outside the process: Persisting JWT Signing Keys in Production.

invalid_client from the token endpoint

The secret is wrong, or the credentials are sent in a way the registration does not allow. A client registered with client_secret_post sends client_id and client_secret in the form body, as the curl call above does.

invalid_scope from the token endpoint

The provider checks the client's AllowedScopes first. "The scope is not allowed for this client" means the scope is not in that list, and for a client that lists its scopes this also covers a misspelled scope. "The scope is not available" means the scope passed that check but the provider cannot grant it: a resource scope requested without its resource, or a scope the provider does not know at all.

403 from the API

The token is valid but its scope does not contain weather. Check that the request sent scope=weather and that the token response echoes it.

Next steps