CI · OIDC
Validate GitHub Actions and GitLab CI OIDC tokens before granting deploy authority.
CI OIDC tokens replace long-lived secrets in deploy pipelines. The runner asks the CI control plane for a fresh, short-lived token bound to the current job; downstream services validate that token instead of authenticating with a static credential. JWTShield’s /v1/validate/ci-oidc endpoint runs the verification with provider-specific claim assertions baked in.
This guide covers the full integration for GitHub Actions and GitLab CI, including how to fail the build when a token is invalid.
Why validate the token at all
The CI control plane already signs the token, so why re-verify? Three reasons:
- Fork PRs. A workflow triggered by a fork submits a token whose
repositoryclaim names the fork, not the upstream repo. Without explicit checking, a forked PR can request resources scoped to the upstream repo. - Branch hygiene. Production deploys should refuse tokens whose
refis a feature branch or whoseref_protectedisfalse. - Issuer pinning. The token signature must verify against the CI provider’s JWKS, not an attacker-controlled key. JWTShield’s built-in profiles pin the issuer URLs and algorithms; the API is the contract.
GitHub Actions
1. Grant the runner the right to request OIDC tokens
In your workflow, set:
permissions:
contents: read
id-token: write
This makes the env vars ACTIONS_ID_TOKEN_REQUEST_URL and ACTIONS_ID_TOKEN_REQUEST_TOKEN available on the runner.
2. Fetch a token
- name: Retrieve runner OIDC token
id: token
run: |
set -euo pipefail
JWT=$(curl -sSf \
-H "Authorization: bearer ${ACTIONS_ID_TOKEN_REQUEST_TOKEN}" \
"${ACTIONS_ID_TOKEN_REQUEST_URL}&audience=api://jwtshield" \
| python3 -c 'import json,sys; print(json.load(sys.stdin)["value"])')
echo "::add-mask::${JWT}"
echo "jwt=${JWT}" >> "${GITHUB_OUTPUT}"
The audience query parameter should match what JWTShield’s GitHub Actions profile expects. Set it to a stable value like api://jwtshield and configure the same value in your verifier.
Always run echo "::add-mask::${JWT}" before exporting the token. Without the mask, the token leaks to job logs.
3. Validate via JWTShield
- name: Validate via JWTShield /v1/validate/ci-oidc
env:
JWT: ${{ steps.token.outputs.jwt }}
JWTSHIELD_URL: ${{ vars.JWTSHIELD_URL }}
run: |
set -euo pipefail
BODY=$(python3 -c '
import json, os
print(json.dumps({
"token": os.environ["JWT"],
"provider": "github_actions",
"expected_repository": os.environ["GITHUB_REPOSITORY"],
"expected_ref": os.environ["GITHUB_REF"],
}))
')
RESULT=$(curl -sSf -H "Content-Type: application/json" \
--data "${BODY}" "${JWTSHIELD_URL}/v1/validate/ci-oidc")
echo "${RESULT}" | python3 -m json.tool
echo "${RESULT}" | python3 -c '
import json, sys
if not json.load(sys.stdin).get("valid"):
sys.exit(1)
'
The job fails when valid: false. The findings array is included in the printed result for triage.
GitLab CI
1. Declare the id_tokens block
GitLab 16+ replaced CI_JOB_JWT_V2 with explicit id_tokens:
validate-oidc:
stage: validate
image: python:3.12-slim
id_tokens:
JWTSHIELD_OIDC_TOKEN:
aud: "api://jwtshield"
The aud value must match what your verifier expects.
2. Validate via JWTShield
before_script:
- apt-get update && apt-get install -y --no-install-recommends curl
script:
- |
set -euo pipefail
BODY=$(python3 -c "
import json, os
print(json.dumps({
'token': os.environ['JWTSHIELD_OIDC_TOKEN'],
'provider': 'gitlab',
'expected_project_path': os.environ.get('CI_PROJECT_PATH', ''),
'expected_ref_protected': 'true' if os.environ.get('CI_COMMIT_REF_PROTECTED', 'false') == 'true' else 'false',
}))
")
RESULT=$(curl -sSf -H "Content-Type: application/json" \
--data "${BODY}" "${JWTSHIELD_URL}/v1/validate/ci-oidc")
echo "${RESULT}" | python3 -m json.tool
echo "${RESULT}" | python3 -c "
import json, sys
if not json.load(sys.stdin).get('valid'):
sys.exit(1)
"
3. Restrict to protected refs
Add a rule so the job only runs on the default branch and merge requests:
rules:
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
Provider-specific claims
| Field | GitHub Actions | GitLab CI |
|---|---|---|
expected_repository | Asserts repository claim. Use ${{ github.repository }}. | — |
expected_ref | Asserts ref claim. Use ${{ github.ref }} (e.g. refs/heads/main). | — |
expected_project_path | — | Asserts project_path claim. Use $CI_PROJECT_PATH. |
expected_ref_protected | — | Asserts ref_protected claim. Pass "true" to require protected refs. |
Each unmatched expectation surfaces a finding documented in Errors — GITHUB_REPO_MISMATCH, GITHUB_REF_MISMATCH, GITLAB_PROJECT_MISMATCH, GITLAB_REF_PROTECTION_MISMATCH.
Failing the build
JWTShield always returns HTTP 200 with valid: true|false. Three patterns:
# Bash + jq
jq -e '.valid' <<<"$RESULT" >/dev/null || exit 1
# Bash + python (no jq dependency)
echo "$RESULT" | python3 -c 'import json,sys; sys.exit(0 if json.load(sys.stdin)["valid"] else 1)'
# Bash + curl --fail-with-body (fails on non-2xx, but JWTShield uses 200 for invalid tokens)
# Don't rely on --fail; you must inspect the body.
Reference
POST /v1/validate/ci-oidc— full request and response shape.POST /v1/test/auth-regression— bundle multiple OIDC checks (e.g. positive + negative fixtures) into a single CI step.- Errors — every code that can land in
findings[].