Machine-to-Machine Identity
Authenticating services rather than people — and getting rid of long-lived secrets entirely.
Service-to-service traffic has no human, no browser, and no consent screen. It usually also has the widest permissions in the system, which makes it worth more care than it typically gets.
The options, weakest to strongest
| Mechanism | Secret | Rotation | Good for |
|---|---|---|---|
| Static API key | long-lived | manual | simple, external integrations |
| OAuth client credentials | long-lived, used rarely | token expires hourly | standard, gateway-friendly |
| Private key JWT | never transmitted | key rotation | cross-organisation |
| mTLS | never transmitted | automatic in a mesh | internal, regulated |
| Workload identity | none | automatic | cloud-native |
Workload identity: no secret at all
This is the direction everything is moving. The platform gives your workload a short-lived, cryptographically verifiable identity — a projected Kubernetes service account token, an IMDSv2 credential on EC2, a metadata-server token on GCP — and you exchange it for whatever access you need.
Nothing static exists to leak. There is no secret in a repository, an environment variable, a CI variable, or a Slack message. If your services run in a cloud or on Kubernetes, this should be the default and everything else a fallback.
SPIFFE / SPIRE
A vendor-neutral form of the same idea: every workload gets a SPIFFE ID (spiffe://abc.com/ns/prod/sa/orders) and a short-lived X.509 certificate or JWT proving it. Service meshes use this to give you mTLS between every pod with certificates that rotate hourly and no application code involved.
Rules regardless of mechanism
- One identity per service. A shared credential makes an audit log useless and a rotation impossible.
- Scope narrowly. The reporting service reads; it does not write.
- Short-lived beats long-lived, always.
- Log the caller identity on every request. "Who deleted this?" must have an answer.
- Rotate on a schedule, not on incident. If rotation is scary, it is broken.
Example
# Kubernetes: the pod is handed a short-lived, audience-bound token — no Secret object
apiVersion: v1
kind: Pod
spec:
serviceAccountName: orders-service
containers:
- name: app
volumeMounts:
- name: token
mountPath: /var/run/secrets/tokens
volumes:
- name: token
projected:
sources:
- serviceAccountToken:
path: api-token
audience: https://dfg.com/api # bound to ONE audience
expirationSeconds: 3600 # and rotated automatically
# The app reads /var/run/secrets/tokens/api-token and exchanges it.
# There is no static credential anywhere in the manifest, the image, or git.When to use it
- A Kubernetes workload exchanges its projected service account token for a cloud access token, so the deployment contains no secret at all.
- A service mesh issues hourly certificates to every pod, making a stolen certificate useless within the hour and rotation invisible to application code.
- An audit traces a deletion to the exact service identity that performed it, because each service has its own credential rather than a shared key.
More examples
Exchanging a workload token for an API token
Re-reading the file each time is essential: the kubelet rewrites the projected token in place, and a process that cached the string starts failing when it rotates.
import fs from 'fs/promises';
const TOKEN_PATH = '/var/run/secrets/tokens/api-token';
let cached = { token: null, expiresAt: 0 };
let inFlight = null;
async function exchange() {
// The platform rotates this file — always re-read it, never cache the contents.
const workloadToken = (await fs.readFile(TOKEN_PATH, 'utf8')).trim();
const res = await fetch('https://auth.dfg.com/oauth/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'urn:ietf:params:oauth:grant-type:token-exchange',
subject_token: workloadToken,
subject_token_type: 'urn:ietf:params:oauth:token-type:jwt',
audience: 'https://dfg.com/api',
scope: 'orders:read',
}),
});
if (!res.ok) throw new Error(`token_exchange_failed_${res.status}`);
const { access_token, expires_in } = await res.json();
cached = { token: access_token, expiresAt: Date.now() + expires_in * 1000 };
return access_token;
}
export async function serviceToken() {
if (cached.token && Date.now() < cached.expiresAt - 60_000) return cached.token;
return (inFlight ??= exchange().finally(() => { inFlight = null; }));
}
// No secret in the image, the environment, the repo, or CI.
const res = await fetch('https://dfg.com/api/orders', {
headers: { Authorization: `Bearer ${await serviceToken()}` },
});Verifying a service caller, and logging it
Rejecting user-shaped claims on a service-only route stops a leaked user token being replayed against internal endpoints that assume a machine caller.
// Machine tokens carry no user. Authorize on the service identity and scope,
// and record who called on every mutating request.
export async function serviceAuth(req, res, next) {
const claims = await verifyAccessToken(readBearer(req)); // iss, aud, exp, sig
// A service token has no 'sub' naming a person — reject one that claims to.
if (claims.act || claims.email) {
return res.status(403).json({ error: 'user_token_not_accepted_here' });
}
const service = claims.sub; // 'reporting-service'
if (!KNOWN_SERVICES.has(service)) {
return res.status(403).json({ error: 'unknown_service' });
}
req.caller = { type: 'service', id: service,
scopes: String(claims.scope || '').split(' ') };
next();
}
app.delete('/api/internal/orders/:id',
serviceAuth,
requireScope('orders:delete'),
async (req, res) => {
await db.orders.delete(req.params.id);
// "Who deleted this?" has an answer, because identities are not shared.
await audit.record({
action: 'order.delete',
target: req.params.id,
actorType: 'service',
actorId: req.caller.id,
requestId: req.get('x-request-id'),
});
res.sendStatus(204);
});
Discussion