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
- 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. - In the job (
permissions: id-token: write), the workflow asks GitHub for an OIDC token whose audience is the API URL, and sends it toPOST /v1/auth/oidcwith the app id. - 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
jtias used (a GitHub token works once); and returns the short token. - 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
| Field | Meaning |
|---|---|
subjectPattern | Pattern 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. |
workflowRef | Optional. 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. |
environment | Optional. The GitHub environment must be exactly this. |
scopes | builds: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_requestandpull_request_targetare refused even with a matching rule.pull_request_targetis 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.
workflowReflimits 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.