Human authentication has a person in the loop: a password, a passkey, a push notification, an MFA prompt. Machine-to-machine (M2M) authentication does not. A batch job at 03:00, a payments service calling a ledger, a Kubernetes pod pulling a config — none of them can answer a challenge, and none of them should hold a password a human typed.
The whole field is one question asked over and over: how does a workload prove which workload it is to another party, without a long-lived secret that can leak? This post is a map of the answers — the standards, what each one actually protects against, and how the identity providers and cloud hyperscalers implement them. Simple terms, diagrams, and code.
The thing we are trying to kill: the shared secret
The default M2M credential is still a static string — an API key, a
client_secret, a database password baked into an environment variable.
It works on day one and it is a liability forever:
- It sits in CI logs,
.envfiles, container layers, and Slack messages. - It does not expire unless a human rotates it, and nobody rotates it.
- If it leaks, the attacker is the service. There is no second factor.
- Possession is the whole proof — anyone holding the string is trusted.
Every standard below is a step away from “a secret you know” toward “an identity you are,” and, at the far end, toward credentials no human ever sees. Here is the ladder:
bearer secret asymmetric key sender-constrained ambient identity
(API key / (private_key_jwt) (mTLS, DPoP) (SPIFFE, cloud WIF)
client_secret)
─────────────▶─────────────────▶──────────────────────▶──────────────────────▶
leaks = game over secret never on wire stolen token is useless no secret to leak;
manual rotation still a long-lived key without the key identity is issued,
short-lived, auto-rotated
Standard 1 — OAuth 2.0 Client Credentials grant
This is the baseline M2M flow, defined in RFC 6749 §4.4. There is no user and no redirect. The client authenticates to a token endpoint and gets back an access token it then presents to APIs.
┌────────┐ ┌──────────────────┐ ┌─────────┐
│ Client │ │ Authorization │ │ API / │
│(service│ │ Server (IdP) │ │Resource │
│ A) │ └──────────────────┘ │ Server │
└───┬────┘ │ └────┬────┘
│ POST /token │ │
│ grant_type=client_credentials │ │
│ + client authentication │ │
│──────────────────────────────────▶│ │
│ ◀───────────── access_token ──────│ │
│ │ │
│ GET /ledger Authorization: Bearer <access_token> │
│───────────────────────────────────────────────────────────▶│
│ ◀──────────────────── 200 OK ─────────────────────────────│
The interesting part is how the client authenticates in step one. That choice is the entire security story, and it is where the rest of the standards live.
# The weakest form: a shared secret in the body (client_secret_post)
curl -s https://idp.example.com/oauth/token \
-d grant_type=client_credentials \
-d client_id=svc-a \
-d client_secret=SUPER_SECRET_STRING \
-d scope=ledger:write
If that client_secret leaks, it is game over — same failure mode as an
API key. The token you get back is a bearer token: whoever holds it can
use it. Two problems to fix, then: the client credential, and the bearer
nature of the token.
Standard 2 — private_key_jwt: kill the shared secret
Instead of sending a secret, the client signs a short-lived JWT with a
private key and sends that as proof. The IdP verifies it with the
matching public key. The private key never leaves the client and never
travels on the wire. Defined by
RFC 7523 and the OpenID
Connect private_key_jwt client-authentication method.
Client holds: private key ──signs──▶ client assertion (JWT, exp ~60s)
IdP holds: public key ──verifies──▶ "yes, this is svc-a"
On the wire: only the signed assertion; the key itself never moves.
import jwt, time, uuid, requests
now = int(time.time())
assertion = jwt.encode(
{
"iss": "svc-a", "sub": "svc-a", # I am svc-a
"aud": "https://idp.example.com/oauth/token",
"jti": str(uuid.uuid4()), # single-use, anti-replay
"iat": now, "exp": now + 60, # valid for 60 seconds
},
private_key_pem, algorithm="RS256",
headers={"kid": "svc-a-2026"},
)
tok = requests.post("https://idp.example.com/oauth/token", data={
"grant_type": "client_credentials",
"scope": "ledger:write",
"client_assertion_type":
"urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
"client_assertion": assertion,
}).json()
A leaked assertion is useless after 60 seconds and can only be used once
(jti). A leaked log contains no reusable secret. This is a real
upgrade — but the token the IdP returns is still a bearer token. That is
the next fix.
Standard 3 — mTLS and certificate-bound tokens (RFC 8705)
Two ideas, one standard (RFC 8705):
- mTLS client authentication — the client authenticates to the token endpoint with a client certificate during the TLS handshake. No secret in the body at all; the private key proves identity.
- Certificate-bound access tokens — the IdP embeds a hash (a
thumbprint) of the client cert into the token (
cnf.x5t#S256). The resource server then only accepts that token over a TLS connection using the same client cert.
This makes the token sender-constrained: a stolen token is worthless without the private key it was bound to.
┌────────┐ TLS w/ client cert C ┌──────┐ token bound to hash(C) ┌─────┐
│ Client │──────────────────────────▶│ IdP │───────────────────────────▶│ API │
│ + key │ └──────┘ └─────┘
│ C │ │
│ │ present token over TLS w/ SAME client cert C │
│ │─────────────────────────────────────────────────────────────────▶
│ │ API recomputes hash(C), compares to cnf.x5t#S256 in token ─────┘
└────────┘ mismatch → 401. Stolen token alone is useless.
// Access token claims — the binding lives in "cnf"
{
"iss": "https://idp.example.com",
"sub": "svc-a",
"scope": "ledger:write",
"cnf": { "x5t#S256": "bwcK0esc3ACC3DB2Y5_lESsXE8o9ltc05O89jdN-dg2" }
// ^ SHA-256 thumbprint of the client's certificate
}
mTLS is the workhorse of high-assurance environments (open banking mandates it) and it is the native language of service meshes — which we will return to with SPIFFE. Its operational cost is PKI: issuing, distributing, and rotating certificates. When you cannot run mTLS end-to-end, there is a lighter way to sender-constrain a token.
Standard 4 — DPoP: sender-constrained tokens without mTLS
DPoP — Demonstrating Proof-of-Possession at the Application Layer, RFC 9449 — gets you the “stolen token is useless” property using an application-layer signature instead of TLS client certs. (If you have heard this called “sPoP” or “proof-of-possession tokens,” this is the standard.)
The client holds a key pair. On every request it sends a DPoP proof: a
small JWT, signed with its private key, covering the HTTP method and URL and
a timestamp. The IdP binds the access token to the public key
(cnf.jkt); the resource server checks that each request carries a fresh
proof signed by the matching key.
┌────────┐ ┌──────┐ ┌─────┐
│ Client │ DPoP proof (JWT │ IdP │ token bound to │ API │
│ holds │ signed w/ privkey)│ │ thumbprint(pubkey) │ │
│ keypair│───────────────────▶│ │──────────────────────▶│ │
└───┬────┘ └──────┘ cnf.jkt = jkt └──┬──┘
│ │
│ GET /ledger │
│ Authorization: DPoP <access_token> │
│ DPoP: <proof JWT: htm=GET, htu=.../ledger, iat, jti> │
│───────────────────────────────────────────────────────────▶
│ API verifies proof sig w/ token's bound key, checks │
│ htm/htu match this request, jti is fresh → 200. ──────────┘
// The DPoP proof JWT sent in the "DPoP" header, per request
// header:
{ "typ": "dpop+jwt", "alg": "ES256",
"jwk": { "kty": "EC", "crv": "P-256", "x": "...", "y": "..." } }
// payload:
{ "htm": "GET", // this HTTP method
"htu": "https://api.example.com/ledger",// this URL
"iat": 1751414400,
"jti": "e1f2...-unique", // anti-replay
"ath": "fUHyO2r2Z3..." } // hash of the access token
The trade-off versus mTLS: DPoP lives in application code and headers, so it works through ordinary HTTPS load balancers and CDNs that would otherwise terminate a client-cert handshake — at the cost of doing crypto per request in your app. Rule of thumb: mTLS where you control the transport and already run PKI (service mesh, open banking); DPoP where you cannot, or for public/mobile clients.
Everything so far still assumes the workload was handed a credential — a secret, a key, a cert — by some provisioning step. The last family removes even that.
Standard 5 — SPIFFE/SPIRE: identity a workload is issued
SPIFFE (Secure Production Identity Framework For Everyone), a CNCF graduated project, defines a universal identity for workloads. SPIRE is its reference implementation. The core idea: a workload should not be given a secret to copy — it should be issued a short-lived identity, automatically, based on what it verifiably is.
Two pieces:
- SPIFFE ID — a URI name for a workload, e.g.
spiffe://prod.example.com/ns/payments/sa/ledger. - SVID (SPIFFE Verifiable Identity Document) — the credential carrying that ID, either an X.509 SVID (a short-lived cert, minutes) or a JWT-SVID. Delivered over the local Workload API — no secret on disk, no secret in env.
The magic is attestation: the SPIRE agent proves a workload’s identity from properties it cannot forge — its Kubernetes service account, its process UID, its cloud instance identity document — before issuing an SVID. No shared secret bootstraps the trust.
┌─────────────┐ ┌──────────────┐ ┌──────────────────┐
│ Workload │ │ SPIRE Agent │ │ SPIRE Server │
│ (pod) │ │ (per node) │ │ (trust root/CA) │
└──────┬──────┘ └──────┬───────┘ └────────┬─────────┘
│ Workload API │ │
│ "who am I?" │ │
│─────────────────────▶│ node + workload │
│ │ attestation │
│ │────────────────────────▶│
│ │ ◀── signed X.509 SVID ──│
│ ◀── SVID (cert, │ (TTL ~ minutes) │
│ auto-rotated) ──│ │
│ │
│ now do mTLS to peer workloads using the SVID │
│ — both sides present SPIFFE IDs, mutually auth │
This is the substrate under service meshes (Istio issues SPIFFE identities to sidecars) and it composes directly with Standard 3: a SPIFFE X.509 SVID is the client cert in an mTLS handshake. A JWT-SVID can be exchanged for an OAuth token, which brings us to the last standard.
Standard 6 — Token Exchange (RFC 8693): the bridge
Real systems mix trust domains: a Kubernetes-issued identity needs to call an OAuth-protected API; a SPIFFE JWT-SVID needs to become a cloud access token. RFC 8693 defines a grant that takes a token from one domain and returns a token for another — the mechanism every cloud’s “workload identity federation” is built on.
# Trade an inbound identity token for an API access token
curl -s https://idp.example.com/oauth/token \
-d grant_type=urn:ietf:params:oauth:grant-type:token-exchange \
-d subject_token="$INBOUND_JWT" \
-d subject_token_type=urn:ietf:params:oauth:token-type:jwt \
-d audience=https://api.example.com \
-d scope=ledger:read
That is the whole conceptual toolkit. Now, who implements it.
Identity providers
The standards are portable; the knobs differ. In IdP terms, an M2M identity is almost always a “client” (OAuth) configured for the client-credentials grant.
- Keycloak (open source, CNCF). A
confidential client with “Service Accounts Enabled” gets the
client-credentials grant. Client authentication supports
client_secret,private_key_jwt(Signed JWT), andX509(mTLS). Certificate-bound tokens are enabled per-client via Advanced → OAuth 2.0 Mutual TLS. DPoP is supported as a preview feature. It can also act as an RFC 8693 token-exchange broker. - Auth0 — Machine-to-Machine Applications
authorized against an API (audience). Supports
client_secret,private_key_jwt, and mTLS with certificate-bound tokens. - Okta — API Services apps; strongly
steers M2M toward
private_key_jwtover client secrets, plus DPoP support for sender-constrained tokens. - Microsoft Entra ID — app registrations with certificate credentials or Federated Identity Credentials (trust an external OIDC issuer — GitHub Actions, Kubernetes — with no secret at all).
- Ping / ForgeRock — the same primitives with heavy mTLS and open-banking (FAPI) pedigree.
Two rules that survive every migration: prefer private_key_jwt or mTLS
over client_secret, and scope narrowly — one client per service,
least-privilege audiences and scopes, so a compromise is contained.
Cloud hyperscalers: workload identity federation
The hyperscalers took SPIFFE’s philosophy — don’t hand out secrets, issue short-lived identity based on what the workload verifiably is — and wired it into their IAM. The common substrate is the Kubernetes projected service-account token: an OIDC JWT the kubelet mints for a pod, signed by the cluster, audience-scoped, and auto-rotated. The cloud IAM trusts the cluster’s OIDC issuer and exchanges that JWT for cloud credentials. No secret is stored in the pod.
┌────────────┐ projected SA token ┌───────────────┐ temp cloud creds ┌────────┐
│ Pod │ (OIDC JWT, signed │ Cloud IAM STS │ (mins–1h TTL, │ Cloud │
│ (K8s SA) │ by cluster issuer) │ trusts cluster│ auto-rotated) │ API │
└─────┬──────┘───────────────────────▶│ OIDC issuer │────────────────────▶│(S3/GCS/│
│ └───────────────┘ │ Blob) │
│ SDK calls cloud API with temp creds ──────────────────────────────▶└────────┘
│ no static key, no secret in the pod
AWS
- IRSA (IAM Roles for Service Accounts) and the newer EKS Pod
Identity. The cluster’s OIDC provider is registered with IAM; a pod’s
SA maps to an IAM role; AWS STS
AssumeRoleWithWebIdentityswaps the projected token for temporary credentials. - IAM Roles Anywhere does the same for workloads outside AWS using X.509 certs — effectively SPIFFE-style mTLS into AWS IAM.
# EKS: bind a Kubernetes SA to an AWS IAM role (IRSA). No keys in the pod.
apiVersion: v1
kind: ServiceAccount
metadata:
name: ledger
annotations:
eks.amazonaws.com/role-arn: arn:aws:iam::111122223333:role/ledger-role
Google Cloud
- Workload Identity Federation. A workload identity pool trusts an external OIDC/SAML issuer (another cluster, GitHub Actions, AWS, Azure). GKE’s built-in Workload Identity maps a Kubernetes SA to a Google service account directly. Credentials are short-lived STS tokens.
Azure
- Microsoft Entra Workload ID. Managed Identities give Azure-hosted workloads an identity with zero credentials in code (the platform injects them via IMDS). Workload Identity Federation extends the same to AKS pods and external issuers through Federated Identity Credentials — a projected SA token is exchanged for an Entra access token, no client secret.
# GitHub Actions → cloud, with NO stored cloud secret.
# The runner presents its OIDC token; the cloud exchanges it for creds.
# (Azure federated-credential example; AWS/GCP have the same shape.)
az login --service-principal \
--username "$AZURE_CLIENT_ID" \
--tenant "$AZURE_TENANT_ID" \
--federated-token "$(cat $ACTIONS_ID_TOKEN_REQUEST_...)"
The pattern is identical across all three: trust an OIDC issuer, exchange its short-lived token for cloud credentials, store nothing. That is RFC 8693 token exchange with cloud-specific spelling.
Choosing: a decision guide
Are both ends inside one cloud / K8s cluster?
└─ yes → use the platform's workload identity
(IRSA / Pod Identity, GKE WI, Entra Managed ID). No secrets.
└─ no → Do you control the transport and run PKI / a mesh?
└─ yes → mTLS + certificate-bound tokens (RFC 8705),
SPIFFE/SPIRE for issuance & rotation.
└─ no → OAuth client-credentials with:
• private_key_jwt (never client_secret)
• + DPoP (sender-constrain the token)
Crossing trust domains (cluster→cloud, cloud→cloud, CI→cloud)?
└─ Workload Identity Federation / token exchange (RFC 8693).
Three principles hold across all of it:
- No long-lived shared secrets. If a human can copy it, so can an attacker. Prefer keys and issued identities over strings.
- Sender-constrain the token. Bearer tokens are stolen and replayed; mTLS- or DPoP-bound tokens are useless without the key.
- Short TTLs and automatic rotation. A ten-minute SVID or a one-hour STS credential shrinks the blast radius to almost nothing — and removes the rotation task humans never do.
The direction of travel is clear: away from secrets a service knows, and toward identities a service verifiably is — issued fresh, bound to the sender, and expired before anyone notices they leaked.