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, onhttps://localhost:5006. It already registers a client for this grant,console_client.ApiSample, the protected API, onhttps://localhost:5004. It accepts a request only when its access token carries theweatherscope.
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:
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:
{
"ClientId": "console_client",
"ClientSecrets": [ { "Sha512Hash": "vSsar3708Jvp9Szi2NWZZ02Bqp1qRCFpbcTZPdBhnWgs5WtNZKnvCXdhztmeD2cmW192CF5bDufKRpayrW/isg==" } ],
"TokenEndpointAuthMethod": "client_secret_post",
"AllowedGrantTypes": [ "client_credentials" ],
"AllowedScopes": [ "weather" ]
}What each setting controls:
ClientSecretsholds 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 withopenssl rand -base64 32and hash it withprintf %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 itsappsettings.json.ClientSecretsis a list so that a secret can be rotated without downtime: add the new hash next to the old one (each entry can carry anExpiresAt), move the service to the new secret, then remove the old entry.TokenEndpointAuthMethodis enforced. A client registered withclient_secret_postsends its credentials in the form body; the same credentials sent in an HTTP Basic header are refused withinvalid_client. The library's default isclient_secret_basic, the method RFC 6749, section 2.3.1 prefers and most client libraries send. The sample registersclient_secret_postonly so that thecurlcall below stays readable; for a real service, registerclient_secret_basicor leave the setting out.AllowedGrantTypeslimits the client to this grant. A service has no use for the authorization code grant, so it should not have it.AllowedScopeslimits which scopes the client may ask for. Leave it out and the client may ask for any scope the provider knows; the sample'sconsole_clientleaves 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 withoutAllowedScopescan 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:
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:5004The response carries the token and how long it lasts:
{
"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
400withinvalid_scopeand "The scope is not available":weatherbelongs 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:5004with an emptyscope, and the API answers403: 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
401withinvalid_token, because the audience is not the one it expects. A provider with a single API can setOidcOptions.DefaultResourceIndicatorto 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:
"JwtBearerAuthentication": {
"Authority": "https://localhost:5001",
"MapInboundClaims": false,
"TokenValidationParameters": {
"ValidTypes": [ "at+jwt" ],
"ValidAudience": "https://localhost:5004",
"ValidIssuer": "https://localhost:5001"
}
}Authorityis 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.ValidIssuermust equal the token'siss, the provider's issuer. LikeAuthority, it names port 5001 in the file and is moved to 5006 in Run it.ValidTypesaccepts access tokens only. An ID token signed by the same provider is refused, even though its signature is valid.ValidAudienceis the resource address registered above. This check is what stops a token issued for another API from being accepted here.MapInboundClaimsset tofalsekeeps the claim names as the token carries them, so the scope is read from a claim calledscope.
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:
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:
dotnet run --project OpenIDProviderApp.MinimalApiIn 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:
JwtBearerAuthentication__Authority=https://localhost:5006 \
JwtBearerAuthentication__TokenValidationParameters__ValidIssuer=https://localhost:5006 \
dotnet run --project ApiSampleIn PowerShell, set both variables and then start the API:
$env:JwtBearerAuthentication__Authority = "https://localhost:5006"
$env:JwtBearerAuthentication__TokenValidationParameters__ValidIssuer = "https://localhost:5006"
dotnet run --project ApiSampleRequest a token with the curl call from Request a token, then call the API with it:
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
- A shared secret is the simplest client authentication method and the easiest to leak. For services you control, consider private_key_jwt or mutual TLS, where the client proves possession of a key and no secret travels.
- The token itself is a bearer token: whoever holds it can use it until it expires. Binding it to the client's key with mutual TLS or DPoP, both of which the provider supports, makes a stolen token useless, as OAuth 2.0 Security Best Current Practice recommends.
- Production Hardening Checklist covers what to change before this provider faces real traffic.
- Securing a React SPA with the BFF Pattern uses the same
ApiSamplewith a signed-in user instead of a service.