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. jwksUri is 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’s sub exactly or with a single *. GitHub subjects look like repo:nuvoralabs/sentinel-examples:ref:refs/heads/main; Kubernetes ones like system: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.