v0.0.1 · system status
Open APIOpenAPI 3.1Zero token storage

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:

  1. Fork PRs. A workflow triggered by a fork submits a token whose repository claim names the fork, not the upstream repo. Without explicit checking, a forked PR can request resources scoped to the upstream repo.
  2. Branch hygiene. Production deploys should refuse tokens whose ref is a feature branch or whose ref_protected is false.
  3. 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.

Note

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

FieldGitHub ActionsGitLab CI
expected_repositoryAsserts repository claim. Use ${{ github.repository }}.
expected_refAsserts ref claim. Use ${{ github.ref }} (e.g. refs/heads/main).
expected_project_pathAsserts project_path claim. Use $CI_PROJECT_PATH.
expected_ref_protectedAsserts ref_protected claim. Pass "true" to require protected refs.

Each unmatched expectation surfaces a finding documented in ErrorsGITHUB_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