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.
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_tokensjob 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/.
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_tokensare Free; nativesecrets:vaultis 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.
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_SOURCEandCI_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?
It proves the trust boundary is enforced and identifies exactly which provider condition rejected the attacker-like claim set.
Why does the local lab use synthetic claims rather than a decoded real token?
It makes policy behavior deterministic without exposing a live bearer assertion in logs, artifacts, or chat/history.
If CI rules exclude feature branches, do provider trust rules still need a branch/ref condition?
Yes. Provider-side authorization is an independent boundary and should remain safe if repository rules are broadened or misconfigured.
What state changes when id_tokens is added to a job?
GitLab is instructed to mint and expose the named OIDC token to that job. It does not automatically change provider trust or grant a cloud role.
What is the strongest evidence that the allowed flow performed the intended action?
The provider allow result plus the exact role/scope metadata and independent read-back/hash of the bounded external target state for the same source/job identity.
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.
- OIDC authentication using ID tokens — official reference.
- Connect to cloud services — official reference.
- CI/CD YAML — id_tokens — official reference.
- AWS OIDC tutorial — official reference.
- Azure OIDC tutorial — official reference.
- Google Cloud workload identity federation — official reference.
- Use HashiCorp Vault secrets — official reference.
- Vault authentication tutorial — official reference.
- External secrets in CI/CD — official reference.
- GitLab deprecations and removals — official reference.
- OpenID Connect Core 1.0 — official reference.
- RFC 7519 — JSON Web Token — official reference.
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.
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0
Send only Ethereum/ERC-20 compatible assets to this
address.