Olympus Docs
CookbookTokens & OAuth2

Service accounts for machine-to-machine

Long-running clients calling your APIs

A background worker, a cron job, a microservice, all need to authenticate to your API. They're not users. They use OAuth2 Client Credentials grant.

Pattern

  1. Create a Hydra OAuth2 client.
  2. Issue it grant_types: client_credentials and the scopes it needs.
  3. Service stores client_id + client_secret in env.
  4. Service calls Hydra /oauth2/token with these → access token (15 min).
  5. Service uses access token to call APIs.
  6. Refreshes when expired.

Create the client

hydra create client \
  --name "Billing Worker" \
  --grant-types client_credentials \
  --token-endpoint-auth-method client_secret_basic \
  --scope "billing:read billing:write" \
  --audience "https://your-api"

Returns client_id and client_secret. Save both.

In the service

// service-account.ts
class ServiceAccount {
  private token: string | null = null;
  private expiresAt = 0;
  
  async getToken(): Promise<string> {
    if (this.token && Date.now() < this.expiresAt - 30_000) {
      return this.token;
    }
    const res = await fetch(`${HYDRA_URL}/oauth2/token`, {
      method: "POST",
      headers: {
        "Content-Type": "application/x-www-form-urlencoded",
        Authorization: `Basic ${btoa(`${process.env.CLIENT_ID}:${process.env.CLIENT_SECRET}`)}`,
      },
      body: "grant_type=client_credentials&scope=billing:read billing:write",
    });
    if (!res.ok) throw new Error(`Token fetch failed: ${res.status}`);
    const { access_token, expires_in } = await res.json();
    this.token = access_token;
    this.expiresAt = Date.now() + expires_in * 1000;
    return this.token;
  }
}

export const account = new ServiceAccount();

Use:

const token = await account.getToken();
await fetch(`${API_URL}/billing/invoices`, {
  headers: { Authorization: `Bearer ${token}` },
});

Secret rotation

When the client_secret should change (90-day rotation, employee left):

# Issue new secret without deleting old
NEW_SECRET=$(openssl rand -hex 32)
hydra update client billing-worker \
  --secret $NEW_SECRET \
  --keep-old-secret

Now the client has two valid secrets. Update env in the service:

# Update env var
sudo systemctl edit billing-worker  # change CLIENT_SECRET
sudo systemctl restart billing-worker

After verifying the service works:

hydra update client billing-worker --secret $NEW_SECRET  # removes old

Audit logs

Service account actions appear in audit logs as:

{
  "actor_type": "client",
  "actor_id": "billing-worker",
  "event": "invoice_paid",
  "target": "user-123"
}

Not as a user (no sub). Audit query can filter by client_id when investigating.

Per-service vs shared

Per-service (recommended): each worker / cron / microservice has its own client.

Pros:

  • Granular scopes, billing-worker doesn't need analytics scopes.
  • Rotate one without others.
  • Audit trail attributes to specific service.

Cons:

  • More clients to manage.

Shared: one client for all background work.

Pros: simpler. Cons: blast radius, can't differentiate in audit, harder to rotate.

For ~10 services, prefer per-service.

In Kubernetes / containers

Mount as env via secret:

# k8s
env:
  - name: CLIENT_ID
    valueFrom: { secretKeyRef: { name: billing-worker-oauth, key: client_id } }
  - name: CLIENT_SECRET
    valueFrom: { secretKeyRef: { name: billing-worker-oauth, key: client_secret } }
# Compose
billing-worker:
  environment:
    CLIENT_ID: ${BILLING_CLIENT_ID}
    CLIENT_SECRET: ${BILLING_CLIENT_SECRET}

Never commit secrets to git.

Scope-of-least-privilege

Each service account should have only the scopes it needs.

# Right
hydra create client --scope "billing:read"  # read-only worker

# Wrong
hydra create client --scope "billing:* users:* admin:*"  # too much

Audit periodically: are scopes still needed?

What about act (acting as a user)?

Service acts on its own. Sub = client_id, not a user. If a service needs to do something on behalf of a user (e.g., bot in Slack acting as the user who triggered it):

  • Token Exchange grant (RFC 8693).
  • sub = user, act.sub = client.

See Admin impersonate user for act claim.

When client credentials AREN'T the answer

  • Direct user calls (use Authorization Code).
  • One-shot script (no need for a permanent client, admins can use their personal token).
  • 3rd-party integrations (give them their own client, but with refresh tokens, not client credentials, usually they need user context).

Failure modes

Token endpoint down

Worker can't get a new token. Fail open or fail closed?

Fail closed: stop processing. Retry later.

try {
  const token = await account.getToken();
} catch (err) {
  log.error("token_fetch_failed", err);
  // Don't process this batch; pause for 60s.
}

Secret rotated but env not updated

Worker is using old secret, gets invalid_client. Alert; redeploy with new env.

Best: rotate secret in env BEFORE deactivating the old one in Hydra.

On this page