Articles · Machine Identity
Workload identity federation
Try out the running example
Every CI secret is a small standing liability: minted once, copied into a secret store, rotated never, leaked eventually. Meanwhile the platforms running your workloads — GitHub Actions, Kubernetes, the clouds — already prove identity cryptographically, issuing short-lived OIDC tokens that say exactly which repo, branch and job is running. Workload federation lets Sentinel accept those proofs directly. The deploy secret’s job ceases to exist.
The shape of the exchange
GitHub Actions job Sentinel
────────────────── ────────
1. requests its OIDC token from GitHub
2. POST /oidc/workload/token ─────────► validates signature against the
subject_token=<github jwt> issuer's JWKS, matches a trust,
checks audience/subject/claims
3. ◄─────────────────────────────────── short-lived Sentinel access token
minted for the trust's service account
Configure a trust
A WorkloadTrustConfig binds an external identity shape to a Sentinel
service account — declaratively:
workloadTrusts:
- serviceAccount: ci-deployer # the principal being impersonated
issuer: https://token.actions.githubusercontent.com
audience: sentinel
subjectPattern: "repo:nuvoralabs/*" # single-* wildcard
claimRules:
- claim: ref
value: refs/heads/main # main-branch jobs only
The pieces:
issuer— where the external token must come from.jwksUriis optional; omitted, Sentinel discovers it via{issuer}/.well-known/openid-configuration. Keys are cached (10-minute TTL, serve-stale-on-error) — no per-request JWKS fetches.subjectPattern— matches the token’ssubexactly or with a single*. GitHub subjects look likerepo:nuvoralabs/sentinel-examples:ref:refs/heads/main; Kubernetes ones likesystem:serviceaccount:ci:deployer. Two stars matches nothing — the wildcard grammar refuses to be clever.claimRules— additional exact-match requirements (ref,repository_owner,environment… whatever the issuer mints).
The exchange call
From a GitHub Actions job (permissions: id-token: write):
- name: Get Sentinel token
run: |
GH_TOKEN=$(curl -s -H "Authorization: Bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \
"$ACTIONS_ID_TOKEN_REQUEST_URL&audience=sentinel" | jq -r .value)
curl -s https://id.example.com/oidc/workload/token \
-d grant_type=urn:ietf:params:oauth:grant-type:token-exchange \
-d subject_token_type=urn:ietf:params:oauth:token-type:jwt \
-d "subject_token=$GH_TOKEN" \
-d audience=example-api
{
"access_token": "eyJ…",
"issued_token_type": "urn:ietf:params:oauth:token-type:access_token",
"token_type": "Bearer",
"expires_in": 600
}
The endpoint is deliberately its own route (POST /oidc/workload/token, mounted by
MapSentinelWorkloadFederation()) rather than a grant on /oidc/token — external-token
validation has a different threat model than client authentication, and the separation
keeps both auditable. The grant-type parameter is RFC 8693’s token-exchange URN
(optional in the request; if present it must match).
The minted token is minted for the trust’s service account — its permissions are the
service account’s grants, evaluated by the
same engine as everyone else’s — plus a
wtrust claim naming the trust that authorized the exchange, so every downstream audit
line can answer “which trust let this workload in?”. No refresh token is issued:
workloads re-exchange; they never hold long-lived credentials. The end-to-end HTTP test
is Github_style_token_exchanges_for_a_working_sentinel_access_token.
Denials: quiet outside, precise inside
A failed exchange tells the caller almost nothing — and tells you everything. The
workload.exchange_denied event records the exact reason: unknown_issuer,
jwks_unavailable, token_rejected:{err}, subject_mismatch,
claim_mismatch:ref, no_matching_trust, and friends. Wire it to a
webhook — a burst of subject_mismatch from a real issuer is
someone probing your trust boundaries with valid-but-wrong tokens, which is exactly the
alert you want.
Kubernetes, same recipe
workloadTrusts:
- serviceAccount: batch-runner
issuer: https://kubernetes.default.svc.cluster.local
jwksUri: https://kubernetes.default.svc.cluster.local/openid/v1/jwks
audience: sentinel
subjectPattern: "system:serviceaccount:jobs:*"
Pods use their projected service-account token as the subject_token — the kubelet
rotates it, you store nothing.
What you deleted, and what remains
Gone: the Sentinel credential in GitHub’s secret store, its rotation calendar, and its
blast radius. What remains is governance you can see: a trust is one declarative
entry, suspending it (status: suspended) instantly cuts the pipeline off, and every
exchange — allowed or denied — is on the record. Compare that to answering “which of our
47 CI secrets can deploy to prod?” from memory.
Scope the service accounts like you mean it: deploy:global:execute for the deployer,
not *:*:* — the machine identity docs cover the
owner-capped API-key alternative for humans’ automation, and why service accounts fit
CI better.