Identity tokens

On this page 5

A job can ask this instance for a short-lived token that says who it is, and present that to a cloud instead of a stored access key.

The problem it removes: a deploy job needs to prove its identity, and the obvious answer - a long-lived key in a secret - is a credential that lives forever, works from anywhere, and is one leaked log away from being somebody else's. A token minted here lasts fifteen minutes, names exactly which repository and which ref asked for it, and is verifiable by anybody who can fetch this instance's public key.

Asking for one

  deploy:
    runs-on: ubuntu-latest
    environment: production
    steps:
      - run: |
          aws sts assume-role-with-web-identity \
            --role-arn "$ROLE" \
            --role-session-name reviewos \
            --web-identity-token "$(reviewos-oidc sts.amazonaws.com)"

reviewos-oidc [audience] is on the PATH in every job. The audience is whatever the other side insists on - sts.amazonaws.com for AWS - and defaults to this instance's own URL, because a token with no audience is one any service could be persuaded to accept.

The endpoint underneath is POST /api/runner/oidc, authenticated with the job credential.

What the token says

The claim names are GitHub's, deliberately: a cloud trust policy is a document somebody writes once and forgets, and the ones people already have are written against these names. Inventing better ones would mean everybody rewriting a policy to gain nothing.

ClaimMeans
subrepo:owner/name:ref:refs/heads/main, or :environment:production, or :pull_request
repository, repository_owner, repository_visibilityWhich repository, whose, and whether it is public
run_id, run_number, run_attemptWhich run, and which attempt of it
workflow, workflow_ref, job_workflow_refWhich file, at which ref
ref, ref_type, shaWhat was being built
event_name, actorWhat started it and who
environmentThe deployment environment, when the job named one
runner_environmentself-hosted

Every one of them comes from the run, not from the request. The only thing a caller chooses is the audience. A token whose repository came from the body would be a token any job could mint for any repository, which is the thing this replaces.

An environment makes the subject more specific, which is what somebody means by "only the production deploy may assume this role":

repo:acme/api:environment:production

Verifying one

Two documents, at the root and public:

GET /.well-known/openid-configuration
GET /.well-known/jwks.json

The path is fixed by the specification and by every implementation of it, which is why they are not under /api: a document AWS will never ask for is a document that does not exist. Both are uncredentialed, because whoever is checking a signature has no account here - and neither contains a secret, since a public key is a thing you publish.

Registering this instance with a cloud is the ordinary flow: give it the issuer (https://your-instance), and it fetches the rest.

Rotation

instance_keys holds the signing keys. A rotation generates a new key that signs from that moment, and the old one keeps verifying: a token signed a minute before still has fourteen minutes to live, and a JWKS holding only the newest key would make those unverifiable - which is a rotation that takes an outage with it, and therefore a rotation nobody performs.

Every token carries the kid of the key that signed it, so a verifier picks the right one without guessing.

The private halves are encrypted with APP_KEY, like workflow secrets, and for a stronger reason: this key signs statements a cloud provider will act on. A database backup that leaks it is somebody able to mint a token for any repository here.

What a fork gets

Nothing. By the threat model an untrusted run receives no credentials, and "I am acme/api on main" is the strongest credential this instance can issue. The refusal says so rather than answering with an empty token, which would fail later and somewhere else.