Heimdall guides
05 · M2M

Authenticate backend services.

When your code talks to Heimdall 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 Heimdall 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 Heimdall 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 (Operations → Credentials → Create M2M) or via the heimdall-admin API.

POST /v1/apps/:appId/credentials (Heimdall-admin)
curl https://api.heimdall.productcraft.co/v1/apps/<appId>/credentials \
  -H 'authorization: Bearer pcft_live_...' \
  -H 'content-type: application/json' \
  -d '{
    "name": "billing-cron",
    "scopes": ["user.read", "invoice.read", "invoice.update"]
  }'

Response (the only time the secret is returned):

{
  "id":            "5e2a...",
  "client_id":     "m2m_a1b2c3d4e5f6",
  "client_secret": "secretvaluethatonlyappearsonce",
  "name":          "billing-cron",
  "scopes":        ["user.read", "invoice.read", "invoice.update"],
  "created_at":    "2026-05-11T..."
}

Store both client_id and client_secret in your secret manager. Heimdall stores only the hash of the secret — there's no recovery. If you lose the secret, rotate (step 6) and update the secret store.


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.heimdall.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; you can also hit /verify and /authorize to check end-user tokens.

GET /<appSlug>/v1/admin/users
curl https://api.heimdall.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.

Heimdall enforces the scope set both at exchange time (the resulting token can't have more scopes than the credential) and at request time (the per-app permission guard checks scope membership for M2M tokens). Two consequences:

  • 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/:id/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. Same endpoint as mint.

POST /v1/apps/:appId/credentials/:id/rotate
curl -X POST https://api.heimdall.productcraft.co/v1/apps/<appId>/credentials/5e2a.../rotate \
  -H 'authorization: Bearer pcft_live_...'

Response shape is identical to mint — the new secret is returned exactly once.

The old secret stops working immediately. Already issued access tokens stay valid until they expire (1h max). If you need to revoke access tokens too, deactivate the credential first (POST .../deactivate or via the console toggle), wait for the TTL to drain, then rotate.

last_rotated_at is set on the credential row. The console surfaces this — “Last rotated 47 days ago” lets your team enforce a rotation cadence.


7

last_used_at — confidence your credential is in use

Every successful exchange stamps last_used_at on the credential row. Surfaces in the console as 'Last seen 12 minutes ago'.

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.heimdall.productcraft.co/acme/v1/oauth/token',
    {
      method: 'POST',
      headers: { 'content-type': 'application/json' },
      body: JSON.stringify({
        grant_type:    'client_credentials',
        client_id:     process.env.HEIMDALL_CLIENT_ID!,
        client_secret: process.env.HEIMDALL_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 round-trip to heimdall-api + an argon2 verify on the secret. Hammering the endpoint under load will rate-limit you.
  • Don't share M2M credentials across services. One credential per logical caller. The audit log records the credential id on every action — 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.