Skip to main content

OIDC for CI

Instead of storing an API key (mg_live_…) in your repository's secrets, a GitHub Actions job exchanges its own OIDC token for a Moatgator token that lasts 10 minutes. There is nothing long-lived to leak, and every exchange is recorded in your organization's audit trail.

How it works​

  1. In the console (or with POST /v1/apps/{id}/oidc-rules), the app gets a trust rule: which GitHub repository and branch, tag or environment may ask for access, and which scopes they get. With no rule, nobody gets in.
  2. In the job (permissions: id-token: write), the workflow asks GitHub for an OIDC token whose audience is the API URL, and sends it to POST /v1/auth/oidc with the app id.
  3. The API verifies the signature (GitHub's keys, cached and rotated), issuer, audience, expiry and the repository claims; checks the rule; marks the token's jti as used (a GitHub token works once); and returns the short token.
  4. The short token goes in Authorization: Bearer … on the public API v1, with the app's plan limits.

The CLI and the GitHub Action do all of this for you. A manual version:

permissions:
id-token: write
contents: read
steps:
- name: Moatgator token
run: |
OIDC=$(curl -sH "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \
"$ACTIONS_ID_TOKEN_REQUEST_URL&audience=https://api.moatgator.com" | jq -r .value)
TOKEN=$(curl -s https://api.moatgator.com/v1/auth/oidc \
-H 'content-type: application/json' \
-d "{\"token\":\"$OIDC\",\"appId\":\"$MOATGATOR_APP_ID\"}" | jq -r .accessToken)
echo "::add-mask::$TOKEN"
echo "MOATGATOR_TOKEN=$TOKEN" >> "$GITHUB_ENV"

Trust rules​

FieldMeaning
subjectPatternPattern for the token's sub: repo:acme/app:ref:refs/heads/main, repo:acme/app:environment:production, repo:acme/app:ref:refs/tags/v*. * matches any text without a colon.
workflowRefOptional. The workflow file that may ask for a token (job_workflow_ref), for example acme/app/.github/workflows/release.yml@refs/heads/main. Another workflow in the same repository cannot exchange.
environmentOptional. The GitHub environment must be exactly this.
scopesbuilds:write, builds:read, scans:read, threats:read. Never events:ingest or attest:verify: those belong to devices, not CI.

Refused when you create them: a wildcard repository owner (repo:*/app…), a repository name that is only *, and any pattern without a real restriction on ref, environment or workflow (repo:acme/app:* would even accept pull requests). When several rules match, the scopes are the union. At most 20 rules per app.

Extra barriers​

  • Pull request events never exchange a token. pull_request and pull_request_target are refused even with a matching rule. pull_request_target is the dangerous one: it runs a fork's code with the base branch's subject, which would match your production rule.
  • The subject must belong to the repository the token claims, so custom subjects cannot be used to impersonate another repository.
  • workflowRef limits the exchange to one workflow file.

What the API checks in GitHub's token​

RS256 and ES256 only (the algorithm is checked against the key type, so none, HS256 and algorithm confusion are refused); kid required; RSA keys of at least 2048 bits; known iss; exact aud; exp, iat and jti required; at most 1 hour of lifetime; 60 s of clock tolerance; each jti seen once.

Refusals and audit​

Every refusal returns the same 401 (unknown app, no rule, repository outside the rule, expired, replayed…), so an attacker cannot learn which apps have rules. The actual reason (not_trusted, expired, replayed, bad_signature…) goes to the audit trail as oidc.denied, with the sub and never the token. Successful exchanges are oidc.exchanged; creating and deleting rules are oidc.rule_created and oidc.rule_deleted.

Revocation​

  • Deleting the rule cuts access for tokens already issued, immediately (the rule is checked on every use).
  • Revoking Moatgator's signing key invalidates every short token.
  • The token expires by itself in 10 minutes.

Current limits​

  • Only GitHub Actions (https://token.actions.githubusercontent.com) is supported. Other CI providers use API keys for now.
  • This has been tested against issuers with generated keys, not against GitHub's live service.
  • The short token is accepted only by the public API v1; the console keeps using sessions.
  • Rate limits are per rule: all jobs sharing a rule share one bucket.