ShellYard

June 4, 2026 · oauth2 · api · auth · rest · devops

OAuth2 Client Credentials: A Step-by-Step Walkthrough for API Testing

You have a client_id and client_secret and the API keeps returning 401. Here's the full client_credentials flow — token endpoint vs auth endpoint, scopes, common mistakes, and how to attach the token to every subsequent request.

By ShellYard

A team hands you credentials for a new API and the keys look like this:

client_id:     k7nf3p2q9m8w1
client_secret: PD9Wn7VC8AeJK_R…
token_url:     https://auth.example.com/oauth/token

You paste the client_id and client_secret into your API client's Basic Auth fields, fire a request at the API, and get back:

HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer error="invalid_token"

The client_id and client_secret aren't the credentials; they're the credentials you trade for the credentials. The actual access token comes from a separate HTTP call to a separate endpoint, and the flow that defines this is OAuth2 client_credentials. Here's how it works end to end, including the spots where most people get stuck.

Which flow this is, and when to use it

OAuth2 defines several "grants" — workflows for different situations. The four you'll see in API testing:

  • authorization_code — the user-in-browser flow, where a human clicks "Allow." You don't use this for server-to-server APIs.
  • client_credentials — the machine-to-machine flow. Your client is the principal; there's no user. You exchange a client_id + client_secret for an access token.
  • password — legacy, deprecated, please don't use.
  • refresh_token — used to renew an access token without going through the user flow again.

For testing a backend API that doesn't represent a logged-in user — internal APIs between services, third-party APIs that authenticate "the app" rather than "this person" — client_credentials is what you want.

The two-endpoint dance

The mental model that fixes the confusion: there are two endpoints, and they're not the same.

  1. The token endpoint. This is the OAuth2 server. You POST your client_id and client_secret here. It returns an access_token. Usually at /oauth/token, /oauth2/v2.0/token, or /connect/token.
  2. The resource API endpoint. This is the actual API you want to test — /v1/customers, /v1/orders, whatever. You send your access_token here as a Bearer header.

The mistake people make under stress: they put the API endpoint's URL in the token-endpoint field, or vice versa. Read the docs once carefully and write the two URLs down separately.

Step 1: Get a token

Here's the curl. Substitute your values:

curl -X POST https://auth.example.com/oauth/token \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "grant_type=client_credentials" \
  -d "client_id=k7nf3p2q9m8w1" \
  -d "client_secret=PD9Wn7VC8AeJK_R" \
  -d "scope=read:customers write:orders"

A successful response looks like this:

{
  "access_token": "eyJhbGciOiJIUzI1NiIs…",
  "token_type": "Bearer",
  "expires_in": 3600,
  "scope": "read:customers write:orders"
}

The access_token is what you'll attach to subsequent requests. expires_in is in seconds (3600 = 1 hour). After that, the token is dead and you have to fetch a new one.

Step 2: Attach the token to the actual API request

Once you have the access_token, use it as a Bearer:

curl https://api.example.com/v1/customers/123 \
  -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIs…"

That's the entire flow. The first request is "get me a token"; every request after that is "here's my token, give me the data."

The five mistakes that produce 99% of OAuth2 frustration

When client_credentials doesn't work, it's almost always one of these:

1. Hitting the wrong endpoint. The OAuth2 spec defines /authorize (for user flows) and /token (for machine flows). client_credentials goes to /token. If you're POSTing to /authorize, you'll get a 400 with a confusing redirect-related error. Check the docs and confirm you're hitting the token endpoint.

2. Sending credentials in the wrong place. Some IdPs (Identity Providers) want client_id + client_secret as form-body fields (what the curl above does). Others want them as HTTP Basic Auth in the Authorization header of the token request itself. A few accept both. If the body-form approach 401s, try Basic:

curl -X POST https://auth.example.com/oauth/token \
  -u "k7nf3p2q9m8w1:PD9Wn7VC8AeJK_R" \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "grant_type=client_credentials" \
  -d "scope=read:customers"

This puts the credentials in Authorization: Basic base64(client_id:client_secret) instead. RFC 6749 calls this "HTTP Basic authentication" of the client; the form-body approach is "client_secret_post." Your IdP supports one or both. Their docs will name which.

3. Wrong Content-Type. The token endpoint expects application/x-www-form-urlencoded. Sending JSON (Content-Type: application/json) gets you a 400 from most IdPs. The -d "key=value" shape in curl sets the form encoding by default; if you've been pasting JSON bodies all day it's easy to forget the switch.

4. Missing or wrong scopes. scope is a space-separated list of permissions the token represents. If you ask for scope=admin but the client isn't authorized for admin, the IdP either returns a 400 (invalid_scope) or returns a token with reduced scope. If the IdP gives you a token but the resource API returns 403 on the actual request, the token's scope is the first thing to check.

5. Forgetting that the token expires. expires_in: 3600 means the token works for an hour and then suddenly doesn't. If you debug a working request, walk away for lunch, come back, and now everything returns 401 — your token has expired. Fetch a new one.

What a real client does for you

Doing this by curl is fine for understanding the flow; doing it by curl for every request is masochism. Any decent API client has an OAuth2 client_credentials preset that:

  1. Holds the token URL, client_id, client_secret, and scopes in one config block.
  2. Fetches a token automatically before the first request.
  3. Caches the token until it's close to expiry.
  4. Re-fetches a new one when the cached one expires.
  5. Injects Authorization: Bearer … into every request in the collection.

You configure once and forget. In Postman this is the Authorization → OAuth 2.0 → Get New Access Token dialog. In ShellYard it's the OAuth2 preset on the request or collection. The mechanics are the same; the UI's different. (And as called out in the Postman migration post, the OAuth2 config migrates cleanly between clients via Postman v2.1 export — the cached tokens don't, but those are short-lived secrets anyway.)

Refresh tokens (when client_credentials gets them)

By default, client_credentials does NOT issue a refresh token. The reasoning is that the client has the client_id and client_secret already — it can just re-run the client_credentials flow to get a fresh access_token. So if your response includes only access_token and not refresh_token, that's correct behavior.

Some IdPs (notably Auth0, Okta in certain configs) optionally issue refresh tokens for client_credentials when explicitly requested. Read your IdP's docs; the default is "no refresh token, just re-run the flow."

Inspecting what's in your token

The access_token is usually a JWT — three base64-encoded sections joined by dots. You can decode it without verifying the signature to see what claims it carries:

echo "eyJhbGciOiJIUzI1NiIs…" | cut -d. -f2 | base64 -d

This shows the payload — iss (issuer), aud (audience, usually your API's URL), exp (expiry epoch), scope, and any custom claims your IdP adds (tenant ID, role, etc.). If the resource API is returning 403 despite a valid token, decoding the JWT and comparing the aud and scope claims against what the API expects is usually the fastest debug step. (Most desktop clients have a JWT inspector built in; the ShellYard toolkit ships one under the encoders tool.)

Do not trust the contents of a JWT you decoded without signature verification on the server side. The decode here is for your debugging, not for security decisions. The resource API verifies the signature against the IdP's public key before honoring any claim.

Where this fits in the cluster

If you've migrated from Postman and one of your collections uses OAuth2 client_credentials, the config travels via v2.1 export (covered in the migration walkthrough), but you re-fetch a token on the other side. If you're new to GraphQL, the same OAuth2 setup applies — and you'll want to make sure the auth config gets sent on introspection requests too, which is the bug the GraphQL introspection post walks through.

For the broader frame on what an account-free API client should handle, the pillar guide for this series lists OAuth2 presets as one of the must-have surfaces.

If you want a client where OAuth2 client_credentials is one of four built-in auth presets — alongside Basic, Bearer, and API key — sitting next to a real JWT inspector for debugging tokens, ShellYard does all of this in one window. Free, local, no account: download it here.