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
- Create a Hydra OAuth2 client.
- Issue it
grant_types: client_credentialsand the scopes it needs. - Service stores
client_id+client_secretin env. - Service calls Hydra
/oauth2/tokenwith these → access token (15 min). - Service uses access token to call APIs.
- 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-secretNow 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-workerAfter verifying the service works:
hydra update client billing-worker --secret $NEW_SECRET # removes oldAudit 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 muchAudit 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.