Secure M2M APIs with Client Credentials and Scoped Access Tokens
For developers wiring up service-to-service authentication on HTTP APIs. This shows the exact token request, JWT validation settings, scope checks, and verification commands so one service can call another with least-privilege access and predictable failure modes.
TL;DR — Use the OAuth 2.0 client credentials grant for service-to-service calls, issue short-lived access tokens with explicit scopes like
orders:read, and reject every token that failsiss,aud,exp, signature, or scope checks. The most common breakage is an audience mismatch: the token is valid, but your API returns401becauseauddoes not exactly match the API identifier your resource server expects. Reading time: ~5 min
Goal
When you finish, one backend service will obtain an access token with the client credentials grant, call your API over HTTPS with Authorization: Bearer ..., and your API will accept only tokens signed by your identity provider, issued for your API audience, and carrying the required scope for each endpoint.
Prerequisites
- An OAuth 2.0 / OpenID Connect identity provider that supports the client credentials grant and JWKS discovery
- Permission to create: one API/resource identifier, one machine client, and one or more scopes in your identity provider
- Your API base URL, for example
https://api.example.com - Your API audience/resource identifier, for example
https://api.example.com/ - A client ID and client secret for the calling service
curl >= 8— check with:
curl --version
jq >= 1.6— check with:
jq --version
- A way to edit your API config and restart it
- If your API validates JWTs locally: outbound access from the API host to the IdP JWKS URL
Steps
Step 1: Define the API audience and scopes in your identity provider
In your provider's dashboard, create an API/resource server with these literal values:
- Audience / Resource Identifier:
https://api.example.com/ - Scopes:
orders:readorders:writehealth:read
Create a machine client/application and grant it only the scopes it needs. For a read-only worker, grant:
orders:readhealth:read
What you should see when this succeeds: the client details page shows a client ID, a client secret, and the API grant lists exactly the scopes above.
Step 2: Record the issuer and JWKS endpoints
Get the OpenID configuration from your IdP. Replace IDP_ISSUER with your issuer base URL, for example https://login.example-idp.com.
export IDP_ISSUER="https://login.example-idp.com"
curl -fsS "$IDP_ISSUER/.well-known/openid-configuration" | jq '{issuer, token_endpoint, jwks_uri}'
Expected output shape:
{
"issuer": "https://login.example-idp.com",
"token_endpoint": "https://login.example-idp.com/oauth2/token",
"jwks_uri": "https://login.example-idp.com/.well-known/jwks.json"
}
What you should see when this succeeds: valid JSON with issuer, token_endpoint, and jwks_uri fields.
Step 3: Request an access token with client credentials
Export your values, then request a token. Replace the placeholders exactly.
export TOKEN_ENDPOINT="https://login.example-idp.com/oauth2/token"
export CLIENT_ID="m2m-client-id"
export CLIENT_SECRET="m2m-client-secret"
export AUDIENCE="https://api.example.com/"
export SCOPE="orders:read health:read"
curl -fsS -u "$CLIENT_ID:$CLIENT_SECRET" \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=client_credentials" \
-d "audience=$AUDIENCE" \
-d "scope=$SCOPE" \
"$TOKEN_ENDPOINT" | tee /tmp/token.json | jq .
Expected output shape:
{
"access_token": "eyJraWQiOiJ...",
"token_type": "Bearer",
"expires_in": 3600,
"scope": "orders:read health:read"
}
What you should see when this succeeds: HTTP 200 and a JSON object containing access_token and token_type Bearer.
Step 4: Inspect the token claims before touching your API
Decode the JWT payload locally and confirm iss, aud, and scope are what your API will check.
ACCESS_TOKEN=$(jq -r '.access_token' /tmp/token.json)
printf '%s' "$ACCESS_TOKEN" | awk -F. '{print $2}' | tr '_-' '/+' | base64 -d 2>/dev/null | jq .
Expected output shape:
{
"iss": "https://login.example-idp.com",
"aud": "https://api.example.com/",
"client_id": "m2m-client-id",
"scope": "orders:read health:read",
"exp": 1799999999,
"iat": 1799996399
}
What you should see when this succeeds: the payload contains your exact issuer, your exact audience, and the granted scopes.
Step 5: Configure your API to validate JWTs and require scopes
Set these environment variables on your API process. Replace values exactly for your environment.
export AUTH_ISSUER="https://login.example-idp.com"
export AUTH_AUDIENCE="https://api.example.com/"
export AUTH_JWKS_URI="https://login.example-idp.com/.well-known/jwks.json"
Enforce these checks in your API middleware, gateway, or reverse proxy:
- Signature validates against
AUTH_JWKS_URI issequalsAUTH_ISSUERaudcontains or equalsAUTH_AUDIENCEexpis in the future- Endpoint scope rules:
GET /healthrequireshealth:readGET /ordersrequiresorders:readPOST /ordersrequiresorders:write
If you use NGINX only as a reverse proxy, do not try to validate JWTs there unless you already run a build/module that supports it. In the common setup, validate in the API application or API gateway.
What you should see when this succeeds: your API starts cleanly and logs the configured issuer/audience without token validation errors on startup.
Step 6: Call the API with the token
Use the token from Step 3 against a read endpoint.
curl -i \
-H "Authorization: Bearer $ACCESS_TOKEN" \
https://api.example.com/orders
Expected success shape:
HTTP/1.1 200 OK
content-type: application/json
[{"id":"ord_123","status":"paid"}]
Now verify scope enforcement by calling a write endpoint with the read-only token.
curl -i -X POST \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{"sku":"ABC-123","qty":1}' \
https://api.example.com/orders
Expected denial shape:
HTTP/1.1 403 Forbidden
content-type: application/json
{"error":"insufficient_scope","required_scope":"orders:write"}
What you should see when this succeeds: the read call returns 200, and the write call with a read-only token returns 403.
Step 7: Cache tokens in the client and rotate secrets safely
Store the token in memory and refresh it before expiry. A simple shell check for expiry looks like this:
jq '{expires_in, scope, token_type}' /tmp/token.json
Use these operating rules in the calling service:
- Refresh when less than 60 seconds remain
- Never request broader scopes than the service needs
- Keep
client_secretin your secret store, not in source control - Rotate the client secret by creating the new secret, deploying it, then revoking the old one
⚠️ Revoking or rotating the only active client secret before the caller is redeployed will break all machine-to-machine calls immediately.
What you should see when this succeeds: the caller reuses tokens until near expiry and continues working after secret rotation without a gap.
Verify it works
Run these checks end to end.
curl -fsS "$IDP_ISSUER/.well-known/openid-configuration" | jq -r '.issuer,.token_endpoint,.jwks_uri'
ACCESS_TOKEN=$(jq -r '.access_token' /tmp/token.json)
curl -i -H "Authorization: Bearer $ACCESS_TOKEN" https://api.example.com/health
curl -i -H "Authorization: Bearer $ACCESS_TOKEN" https://api.example.com/orders
curl -i -X POST -H "Authorization: Bearer $ACCESS_TOKEN" -H "Content-Type: application/json" -d '{"sku":"ABC-123","qty":1}' https://api.example.com/orders
Expected results:
- Discovery endpoint returns
200and prints issuer, token endpoint, and JWKS URI GET /healthreturns200 OKGET /ordersreturns200 OKPOST /orderswith a read-only token returns403 Forbidden
Flow overview:
View as text
[Caller Service] ---- 1. client_credentials ----> [IdP] [IdP] ---- 2. access token (scope, aud) ----> [Caller Service] [Caller Service] ---- 3. Bearer token ----> [API] [API] ---- 4. fetch/refresh JWKS ----> [IdP] [API] ---- 5. 200 or 403/401 ----> [Caller Service]
Common pitfalls
Audience mismatch
Mistake: requesting a token for audience=https://api.example.com while the API validates https://api.example.com/.
Symptom: token request succeeds, but API returns:
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer error="invalid_token", error_description="audience invalid"
Fix: request and validate the exact same audience string, including the trailing slash if that is how the resource server is registered.
Using ID tokens instead of access tokens
Mistake: sending an id_token to the API because it is also a JWT.
Symptom:
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer error="invalid_token"
Fix: send only the access_token from the client credentials response in the Authorization header.
Missing scope on a protected endpoint
Mistake: the client is granted orders:read only, then calls POST /orders.
Symptom:
HTTP/1.1 403 Forbidden
{"error":"insufficient_scope"}
Fix: either grant orders:write to that machine client or call the read endpoint only.
Clock skew on the API host
Mistake: the API server clock is behind or ahead by several minutes.
Symptom:
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer error="invalid_token", error_description="token expired"
for a newly issued token.
Fix: sync the API host with NTP and allow a small clock skew tolerance in JWT validation, typically 60 seconds.
JWKS fetch blocked or cached too aggressively
Mistake: the API cannot reach the JWKS URI, or it pins old signing keys forever.
Symptom: after a key rotation, valid tokens start failing with signature errors.
Fix: allow outbound HTTPS from the API host to the JWKS URI and honor JWKS cache headers or refresh keys on kid miss.
Sending client credentials in the wrong place
Mistake: posting client_id and client_secret in JSON to the token endpoint when the IdP expects HTTP Basic auth and form-encoded fields.
Symptom:
HTTP/1.1 400 Bad Request
{"error":"invalid_client"}
Fix: use -u "$CLIENT_ID:$CLIENT_SECRET" and Content-Type: application/x-www-form-urlencoded exactly as shown in Step 3.
This article was written by an AI system and published pending human review. Verify anything you intend to act on.
Have a project in mind?
Get an instant AI price estimate for it, or talk directly to our team.
One email a month on what we learn building with AI