Chapter 27Lesson 02~235 minutes

ID Tokens, OIDC Workload Identity, Cloud Federation, Vault Authentication, and Secretless Deployments: Guided Hands-On Workflow and Core Operations

Build a disposable local OIDC-trust simulator, compare allowed and denied claim sets, model a Vault/cloud exchange, and preserve evidence without ever printing a live token.

Hands-onMock verifierClaimsAudienceShort-lived credential

Learning objectives

  • Create a deterministic local OIDC trust simulator using synthetic claims only.
  • Prove one main/staging claim set is allowed and feature-branch, wrong-audience, and wrong-environment claim sets are denied.
  • Model the exchange from identity assertion to short-lived provider credential without storing or printing a live token.
  • Preserve pipeline/source/configuration evidence separately from provider authorization and external-action evidence.
  • Map the local model to a real GitLab id_tokens job without making cloud access mandatory.

1. Disposable scenario: a local provider simulator

Create a disposable project named glci-oidc-lab. The “provider” is a Python script using only the standard library. It reads a JSON claim set plus a JSON trust policy, validates issuer/audience/project/ref/environment/protection/time constraints, and—only on success—writes non-secret temporary-credential metadata. A second script simulates one bounded deployment by writing a marker under target/staging/.

Safety boundary: every claim, credential ID, account ID and resource name in this lab is synthetic. Do not paste a real JWT, cloud access key, Vault token, service-account JSON, tenant secret, or production URL into the fixture.

2. Preflight and recorded assumptions

  • Use only a disposable repository you are authorized to change.
  • Python 3.11+ is sufficient; record the exact version actually executed.
  • No network access is required for the mandatory local path.
  • GitLab behavior documented here was verified against primary docs on 2026-09-12.
  • Real GitLab id_tokens are Free; native secrets:vault is Premium/Ultimate; cloud/Vault accounts remain optional.
python --version
git --version
git status --short
git rev-parse HEAD

3. Create the synthetic trust policy

The policy requires one issuer and audience, one stable project ID, the protected main branch, staging environment, and a narrow action scope.

{
  "issuer": "https://gitlab.example.test",
  "audience": "https://sts.example.test",
  "project_id": "42001",
  "ref_type": "branch",
  "ref": "main",
  "ref_protected": true,
  "environment": "staging",
  "deployment_tier": "staging",
  "allowed_actions": ["write:staging-marker"],
  "credential_ttl_seconds": 300
}

This is provider state. Changing .gitlab-ci.yml does not change this policy unless a separate authorized provider-management action does so.

4. Create allowed and attacker-like claim sets

{
  "iss": "https://gitlab.example.test",
  "aud": "https://sts.example.test",
  "sub": "project_path:academy/glci-oidc-lab:ref_type:branch:ref:main",
  "project_id": "42001",
  "job_project_id": "42001",
  "pipeline_source": "push",
  "job_id": "9001",
  "ref": "main",
  "ref_type": "branch",
  "ref_protected": true,
  "environment": "staging",
  "environment_protected": false,
  "deployment_tier": "staging",
  "sha": "1111111111111111111111111111111111111111",
  "iat": 1789230000,
  "exp": 1789230300
}
{
  "iss": "https://gitlab.example.test",
  "aud": "https://sts.example.test",
  "project_id": "42001",
  "job_project_id": "42001",
  "pipeline_source": "merge_request_event",
  "job_id": "9002",
  "ref": "feature/attacker-like",
  "ref_type": "branch",
  "ref_protected": false,
  "environment": "staging",
  "deployment_tier": "staging",
  "sha": "2222222222222222222222222222222222222222",
  "iat": 1789230000,
  "exp": 1789230300
}

Later, clone the allowed fixture and change only aud or environment. That isolates one cause per denial.

5. Implement the local verifier/exchange

import json, sys, time, secrets
from pathlib import Path

claims = json.loads(Path(sys.argv[1]).read_text())
policy = json.loads(Path("trust-policy.json").read_text())
now = int(sys.argv[2]) if len(sys.argv) > 2 else int(time.time())

checks = {
    "issuer": claims.get("iss") == policy["issuer"],
    "audience": claims.get("aud") == policy["audience"],
    "project_id": str(claims.get("job_project_id")) == policy["project_id"],
    "ref_type": claims.get("ref_type") == policy["ref_type"],
    "ref": claims.get("ref") == policy["ref"],
    "ref_protected": claims.get("ref_protected") is policy["ref_protected"],
    "environment": claims.get("environment") == policy["environment"],
    "deployment_tier": claims.get("deployment_tier") == policy["deployment_tier"],
    "time": claims.get("iat", now + 1) <= now < claims.get("exp", 0),
}

decision = "allow" if all(checks.values()) else "deny"
result = {
    "decision": decision,
    "job_id": claims.get("job_id"),
    "sha": claims.get("sha"),
    "audience": claims.get("aud"),
    "checks": checks,
}

if decision == "allow":
    result["credential"] = {
        "credential_id": "mock-" + secrets.token_hex(4),
        "role": "staging-writer",
        "scope": policy["allowed_actions"],
        "expires_in_seconds": policy["credential_ttl_seconds"],
    }

Path("exchange-result.json").write_text(json.dumps(result, indent=2) + "\n")
print(json.dumps({"decision": decision, "failed_checks": [k for k,v in checks.items() if not v]}, indent=2))
sys.exit(0 if decision == "allow" else 3)

The console emits only a decision and failed check names. The result file contains synthetic credential metadata but no bearer secret. That distinction models how production logs should work.

6. Execute allowed and denied cases and preserve both

mkdir -p evidence
python mock_exchange.py claims-allowed.json 1789230100
cp exchange-result.json evidence/allowed-exchange.json

set +e
python mock_exchange.py claims-denied-ref.json 1789230100
rc=$?
set -e
cp exchange-result.json evidence/denied-ref-exchange.json
printf 'denied_ref_exit=%s\n' "$rc" > evidence/denied-ref-exit.txt

test "$rc" -eq 3

Expected evidence: allowed returns exit 0 with every check true; denied returns exit 3 and reports ref/ref_protected as failed. Keep both. A denied identity test is not noise—it proves the trust boundary.

7. Simulate the bounded external action

import json, hashlib
from pathlib import Path
r = json.loads(Path("evidence/allowed-exchange.json").read_text())
if r["decision"] != "allow" or "write:staging-marker" not in r["credential"]["scope"]:
    raise SystemExit("authorization missing")
Path("target/staging").mkdir(parents=True, exist_ok=True)
payload = f"sha={r['sha']}\nrole={r['credential']['role']}\n"
out = Path("target/staging/deployment.txt")
out.write_text(payload)
print("target_sha256=" + hashlib.sha256(out.read_bytes()).hexdigest())

The “deployment” changes only a disposable local file. Authorization success and external side effect remain separate: verify the marker after the exchange instead of declaring deployment successful because a credential was issued.

8. Map the model to a real GitLab job

deploy_staging:
  stage: deploy
  timeout: 10m
  rules:
    - if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH && $CI_COMMIT_REF_PROTECTED == "true"'
  id_tokens:
    STS_ID_TOKEN:
      aud: https://sts.example.com
  environment:
    name: staging
    deployment_tier: staging
  script:
    - ./provider-exchange-and-deploy "$STS_ID_TOKEN"

rules controls whether GitLab creates the job. id_tokens requests the audience-specific assertion. The environment enriches identity/deployment context. The external provider must independently reject any token that violates provider-side trust—even if a CI rule was accidentally broadened.

Defense in depth: never make the provider trust policy merely repeat “job exists.” CI rules are repository-controlled configuration. Provider trust should independently bind the intended project, ref/protection, environment and audience.

9. Wrong-audience experiment

Copy the allowed claims to claims-wrong-aud.json, set aud to https://registry.example.test, run the verifier, and preserve the denial. This proves why a token minted for one service must not automatically authenticate to another.

10. How the same evidence maps to Vault and cloud federation

Target Exchange input Critical trust conditions Returned state
Vault JWT auth GitLab ID token Issuer, bound audience, bound project/ref/environment claims Vault token or requested secret according to Vault policy.
AWS STS GitLab ID token OIDC provider, audience, sub plus supported GitLab condition keys Temporary role credentials.
Azure federated credential GitLab ID token Issuer, subject, audience mapping Temporary Entra/Azure CLI session token.
Google WIF GitLab ID token Issuer/audience, mapped attributes, CEL conditions STS/federated temporary access token.

11. Evidence packet

  • source identity: CI_PIPELINE_SOURCE and CI_COMMIT_SHA (real CI) or fixture SHA (local)
  • compiled job/token request and audience
  • trust-policy file hash
  • allowed and denied selected claims (synthetic only in mandatory lab)
  • provider decision + failed-check names
  • temporary role/scope/expiry metadata, excluding bearer secret material
  • external target read-back/hash
  • tool versions and assumptions/limitations note
sha256sum trust-policy.json claims-allowed.json claims-denied-ref.json
sha256sum evidence/*.json target/staging/deployment.txt
python --version
git rev-parse HEAD

12. Challenge: which layer should reject this?

A feature branch accidentally matches a broad CI rule, so the deploy job exists. The token has the right audience but ref=feature/demo and ref_protected=false. Where should the definitive denial occur?

Answer before revealing the implementation: the provider/Vault trust policy must deny the exchange. Fixing the GitLab rule is still worthwhile, but repository configuration is not a substitute for provider-side authorization.

13. Cleanup

rm -rf target evidence exchange-result.json
# Keep the source fixtures long enough to review the evidence exercise.
# If the GitLab project exists only for this lab, remove only that exact disposable project later.
# Never bulk-delete provider identities, roles, Vault auth mounts, or cloud resources by wildcard.

Knowledge check

Why preserve the denied exchange result?

Why does the local lab use synthetic claims rather than a decoded real token?

If CI rules exclude feature branches, do provider trust rules still need a branch/ref condition?

What state changes when id_tokens is added to a job?

What is the strongest evidence that the allowed flow performed the intended action?

Next lesson

Bridge to design choices

The mechanics are simple. The difficult engineering decision is where to place trust: static secret or federation, project-wide or ref-specific conditions, shared or service-specific audiences, and direct cloud trust or a central Vault broker.

Version and compatibility note

GitLab and GitLab Runner evolve continuously. Treat version-sensitive YAML, runner/executor behavior, APIs, security features, policy controls, tiers, and deprecations as assumptions to verify against the current official GitLab documentation before production use. Preserve the exact project/ref/SHA, compiled configuration, pipeline/job IDs, runner/tool versions, and external target evidence used for any reproducible lab or incident record.

Official references and version notes

Documentation verification date: 2026-09-12. GitLab documents explicit id_tokens as available on Free, Premium, and Ultimate across GitLab.com, Self-Managed, and Dedicated. CI_JOB_JWT/CI_JOB_JWT_V2 were removed in GitLab 17.0; use explicit ID tokens instead. ID tokens are RS256-signed. Their expiry is the job timeout when one is specified, otherwise five minutes. Current custom claims include project/namespace identity, job identity, ref/ref protection, pipeline source, runner identity, source SHA, CI configuration identity, and—when the job declares an environment—environment name/protection/tier/action. GitLab recommends stable IDs such as project/namespace IDs in trust policy conditions where the target provider supports them. Native secrets:vault integration is Premium/Ultimate, while ID-token federation itself is Free. Real provider configuration is optional. If you enable it, capture the provider account/project ID, role/policy identifier, trust-policy revision/hash and credential expiry metadata while keeping token/secret values out of logs and artifacts.

Current assumptions used in this chapter: Version-sensitive YAML, Runner/executor behavior, APIs, security features, policy controls, tiers, and deprecations must be verified against the exact GitLab, GitLab Runner, tool, and external-system versions used in production. Preserve the exact project/ref/SHA, compiled configuration, pipeline/job IDs, runner/tool versions, and external target evidence used for reproducible work.

Keep the academy open

Support free, practical DevOps education.

Every lesson is designed to remain readable in a browser, downloadable from GitHub, and usable without a paid learning platform. Contributions help expand and maintain the curriculum.

Ethereum / ERC-20
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0 Send only Ethereum/ERC-20 compatible assets to this address.