Auth guides
05 · M2M

Authenticate backend services.

When your code talks to Auth on behalf of an automated process — cron jobs, webhook receivers, internal sync — you want an M2M credential. This chapter walks through minting one, exchanging it for an access token, and using it correctly.


1

M2M vs PAK — which one?

Both are workspace-scoped service credentials. The difference is the lane:

  • M2M credential — bound to one Auth app. Exchange for an access token via POST /:appSlug/v1/oauth/token. Use this when your backend acts inside the customer-product context — calling the Consumer admin lane to manage end- users / roles / permissions for that app.
  • PAK — workspace-scoped. Bearer goes on every request directly (no exchange step). Use this when your backend acts on workspace-level resources — minting Auth apps from CI, managing the workspace role catalog, or hitting the verification / reset mint endpoints across multiple apps.

If your service only talks to one app, M2M is the obvious shape: one credential, one app, scoped permissions. If your service crosses apps (e.g. infra-as-code provisioning), a PAK covers them all with one credential.


2

Mint an M2M credential

From the console (Auth → your app → Integration → Credentials → Create M2M credential) or via the auth-admin API. Minting takes a name only; scopes are set in a second call.

POST /v1/apps/:appId/credentials (Auth-admin)
curl https://api.auth.productcraft.co/v1/apps/<appId>/credentials \
  -H 'authorization: Bearer pcft_live_...' \
  -H 'content-type: application/json' \
  -d '{ "name": "billing-cron" }'

# Then bind its scopes (permission strings from the app catalogue):
curl -X PUT https://api.auth.productcraft.co/v1/apps/<appId>/credentials/<client_id>/scopes \
  -H 'authorization: Bearer pcft_live_...' \
  -H 'content-type: application/json' \
  -d '{ "permissions": ["user.read", "invoice.read", "invoice.update"] }'

Create response (the only time the secret is returned):

{
  "id":            "5e2a...",
  "client_id":     "m2m_a1b2c3d4e5f6",
  "client_secret": "secretvaluethatonlyappearsonce",
  "name":          "billing-cron",
  "created_at":    "2026-05-11T..."
}

Store both client_id and client_secret in your secret manager. Auth stores only the hash of the secret — there's no recovery. If you lose the secret, rotate (step 6) and update the secret store. A credential with no scopes exchanges fine but gets an empty scope — set scopes before relying on it.


3

Exchange for an access token

Standard OAuth 2.0 client_credentials grant. POST the client_id + client_secret, get an access token (1h TTL) back.

POST /<app_slug>/v1/oauth/token
curl https://api.auth.productcraft.co/acme/v1/oauth/token \
  -H 'content-type: application/json' \
  -d '{
    "grant_type":    "client_credentials",
    "client_id":     "m2m_a1b2c3d4e5f6",
    "client_secret": "secretvaluethatonlyappearsonce"
  }'
{
  "access_token": "eyJhbGc...",
  "token_type":   "Bearer",
  "expires_in":   3600,
  "scope":        "user.read invoice.read invoice.update"
}

The access token is signed with the app's JWKS, same key your EndUser tokens use. Carries type: "m2m", the resolved scopes[] array, and the same aid/iss claims as EndUser tokens.

Cache the token. Don't exchange on every request — the access token is good for 1h, the exchange is a small but real round-trip. A simple in-memory cache with a 5-minute pre-expiry refresh covers the common case.


4

Use the access token

Bearer on every request to the Consumer API. The /admin/* lane is what M2M is for; POST /verify checks end-user tokens, POST /oauth/introspect inspects your own.

GET /<appSlug>/v1/admin/users
curl https://api.auth.productcraft.co/acme/v1/admin/users \
  -H 'authorization: Bearer <m2m-access-token>'

5

Scope narrowing

The scopes you put on the M2M at mint time bound everything that credential can ever do.

The token's scopes[] claim is stamped at exchange time from the credential's configured set, and the per-app permission guard checks scope membership on every Consumer-API request. Three things to know:

  • Scope checks require enforcement to be on. Until the app's enforce_app_permissions flag is flipped (Chapter 3, step 8), any valid M2M token can call any admin route regardless of scopes. Caller-narrowing on role/permission writes applies either way.
  • Grant the minimum. A billing cron doesn't need role.assign. Don't put scopes on the credential “just in case” — they widen the blast radius if the secret leaks.
  • Re-scoping is destructive on running clients. PUT /v1/apps/:appId/credentials/:clientId/scopes replaces the scope set. Existing access tokens still carry the old scopes until they expire (1h max). If you widen, fine. If you narrow, the running service has up to 1h of stale-broader-than-allowed access.

6

Rotate the secret

When you suspect a leak, when a previous owner of the credential leaves, or on a periodic schedule.

POST /v1/apps/:appId/credentials/:clientId/rotate
curl -X POST https://api.auth.productcraft.co/v1/apps/<appId>/credentials/m2m_a1b2c3d4e5f6/rotate \
  -H 'authorization: Bearer pcft_live_...'

Response is { "client_id": ..., "client_secret": ... } — the new secret, returned exactly once.

The old secret stops working immediately. Already-issued access tokens survive a rotation (they're self-contained JWTs) — to kill those too, deactivate the credential: PATCH .../credentials/:clientId with { "is_active": false } (or the Disable button on the credential row in the console). The admin lane re-checks is_active on every request, so deactivation 401s existing tokens instantly. The row's status badge flips ActiveInactive and the button becomes Enable.

last_rotated_at is set on the credential row and comes back on GET /v1/apps/:appId/credentials — read it from the API if you want to enforce a rotation cadence. The console credential row does not display it today; it shows the credential name, client id, Last used, and the active/inactive badge.


7

last_used_at — confidence your credential is in use

Every successful exchange stamps last_used_at on the credential row. Surfaces on the console credential row as 'Last used · 12m ago' (or 'Never' if it has been minted but not yet exchanged).

Useful for cleaning up: credentials with last_used_at = null after creation almost certainly belong to a service that never integrated. Old last_used_at (months ago) might point to a cron that stopped running.

Caveat: the stamp is best-effort. A transient DB hiccup at exchange time doesn't fail the grant — the access token is issued and the credential works, but last_used_at may not update for that one exchange. Don't treat the field as load-bearing for security decisions; treat it as a signal.


8

Caching pattern

A reference TypeScript snippet for the common 'cache, refresh on expiry' shape.

m2m-token-cache.ts
let cached: { token: string; expiresAt: number } | null = null;

async function getM2mToken(): Promise<string> {
  const now = Date.now();
  if (cached && cached.expiresAt > now + 60_000) {
    return cached.token;
  }

  const res = await fetch(
    'https://api.auth.productcraft.co/acme/v1/oauth/token',
    {
      method: 'POST',
      headers: { 'content-type': 'application/json' },
      body: JSON.stringify({
        grant_type:    'client_credentials',
        client_id:     process.env.AUTH_CLIENT_ID!,
        client_secret: process.env.AUTH_CLIENT_SECRET!,
      }),
    },
  );

  if (!res.ok) {
    cached = null;
    throw new Error(`M2M exchange failed: ${res.status}`);
  }
  const body = await res.json() as { access_token: string; expires_in: number };
  cached = {
    token:     body.access_token,
    // Refresh 1 minute before expiry to avoid edge of the TTL.
    expiresAt: now + body.expires_in * 1000 - 60_000,
  };
  return cached.token;
}

9

Common pitfalls

  • Don't re-exchange on every request. Cache. Each exchange is a full round-trip plus an argon2 verify on the secret — needlessly slow at request frequency.
  • Don't share M2M credentials across services. One credential per logical caller. Audit entries record the acting credential — sharing kills attribution.
  • Don't use M2M for end-user authentication. M2M = backend service. EndUser = your customers' users. The token type claim differs, and several Consumer-API routes (/me/*) are EndUser-only by design.
  • Don't store the secret in env vars committed to your repo. Even private repos. Use your secret manager (Vault, Doppler, AWS Secrets Manager, k8s secret).
  • Rotate periodically. No automated rotation today — the credential's up to you. A 90-day cadence matches our managed RSA-key rotation.