GitLab Secrets and OIDC

Stop storing long-lived credentials in CI variables. Use variable flags correctly for the ones you must keep, and federate via OIDC to Vault, AWS, and GCP for everything else.

The hierarchy
  • Prefer short-lived, federated credentials (OIDC → Vault / AWS / GCP) for deploy tokens, API keys, and cloud access.
  • Use file variables for anything multi-line (SSH keys, CA bundles, kubeconfigs).
  • Use masked protected variables for short tokens that genuinely must live in CI.
  • Scope every sensitive variable to an environment so a feature branch cannot read prod.
  • If a secret leaks to logs, rotate it first. Masking is a usability feature, not a leak-prevention feature.

Variable types and flags

FlagMeaningUse for
ProtectedVariable is exposed only to jobs running on protected branches or tags.Anything prod-related. Always on for secrets.
MaskedGitLab redacts the value from job logs (if it can — see masking limits).Any value you don't want echoed. Always on for secrets.
Expanded (default)GitLab runs $VAR expansion in the value before passing it to the job.Turn off for values that contain $ (certs, JSON blobs, some passwords).
FileJob receives a file path in the variable; file contents are the value. Multi-line safe.SSH keys, kubeconfigs, CA bundles, JSON SA keys.
Environment scopeVariable is only injected for jobs with a matching environment: value.Prod secrets on production, dev on dev/*, etc.

File vs regular variable

deploy:
  environment: production
  script:
    # Regular var: single line, exposed in `env`
    - echo "$API_TOKEN" | wc -c

    # File var: GitLab writes the contents to a temp file and
    # puts the path in the env var. Multi-line safe, no shell escaping.
    - ssh -i "$SSH_KEY" deploy@host "systemctl restart app"
    - kubectl --kubeconfig="$KUBECONFIG_FILE" rollout restart deploy/app
Multi-line SSH keys belong in file variables. Copy-pasting a private key into a regular variable eventually produces a subtle parse error ("invalid format") because \n has been mangled somewhere in the pipeline. File variables skip the whole problem.

Environment scoping

Every sensitive variable must be scoped. Settings → CI/CD → Variables → Environments column:

VariableEnvironment scopeWhy
DEPLOY_SSH_KEY (prod)productionOnly prod deploys see the prod key.
DEPLOY_SSH_KEY (dev)dev/*Glob matches dev, dev/review-123, etc.
VAULT_ROLE* (all)Not a secret; identifies which Vault role to assume.
ANSIBLE_VAULT_PASSWORDproduction / dev/*Different passphrase per env.

Then in the job:

deploy-prod:
  stage: deploy
  environment: { name: production }   # <-- this is what matches the scope
  rules:
    - if: '$CI_COMMIT_BRANCH == "main"'
      when: manual
  script:
    - ansible-playbook -i inventories/prod site.yml

Masking: what it does and does not do

Masking replaces the value with [MASKED] in the job log if the runner sees it verbatim in output. It is the last line of defence, not a substitute for hygiene. Limits:

Corollary: a masked secret is not a safe secret. If CI accidentally set -x's the value, or a tool dumps it to a log with a different encoding, it has leaked and you must rotate.

Hardening against accidental echo

deploy:
  script:
    - set +x                               # never, ever turn this on with secrets
    - : "${VAULT_TOKEN:?missing}"          # fail loudly if the var is empty
    - curl -sSf -H "X-Vault-Token: $VAULT_TOKEN" "$VAULT_ADDR/v1/secret/..." \
        | jq -r .data.data.password        # do not `cat`, do not `echo`

OIDC federation: the idea

Every CI job can request a short-lived JSON Web Token signed by GitLab that contains verifiable claims about what the job is: which project, which branch, whether protected, which user triggered it. Vault, AWS, and GCP can verify that signature and exchange the JWT for their own credentials — no shared secret at rest.

deploy-prod:
  id_tokens:
    VAULT_ID_TOKEN:
      aud: https://vault.example.com
    AWS_ID_TOKEN:
      aud: sts.amazonaws.com

A real JWT payload looks like:

{
  "iss":              "https://gitlab.example.com",
  "aud":              "https://vault.example.com",
  "sub":              "project_path:infra/ansible:ref_type:branch:ref:main",
  "namespace_id":     "42",
  "namespace_path":   "infra",
  "project_id":       "1337",
  "project_path":     "infra/ansible",
  "user_id":          "91",
  "user_login":       "alice",
  "user_email":       "alice@example.com",
  "pipeline_id":      "987654",
  "job_id":           "12345678",
  "ref":              "main",
  "ref_type":         "branch",
  "ref_protected":    "true",
  "environment":      "production",
  "environment_protected": "true",
  "deployment_tier":  "production",
  "iat": 1735689600, "nbf": 1735689600, "exp": 1735693200
}

Every one of those claims is something you can bind against on the consuming side. The interesting ones are project_path, ref, ref_protected, environment, and environment_protected.

Vault via OIDC

Enable JWT auth and point it at GitLab

vault auth enable -path=jwt jwt
vault write auth/jwt/config \
  oidc_discovery_url="https://gitlab.example.com" \
  bound_issuer="https://gitlab.example.com" \
  default_role="ansible-dev"

A prod-bound role

vault write auth/jwt/role/ansible-prod \
  role_type="jwt" \
  user_claim="user_email" \
  token_policies="ansible-prod" \
  token_ttl="20m" \
  token_max_ttl="30m" \
  token_explicit_max_ttl="30m" \
  bound_audiences="https://vault.example.com" \
  bound_claims_type="glob" \
  bound_claims='{
    "project_path":   "infra/ansible",
    "ref":            "main",
    "ref_type":       "branch",
    "ref_protected":  "true",
    "environment":    "production",
    "environment_protected": "true"
  }'

Every claim in bound_claims must match on the incoming JWT or Vault returns invalid_claim. That is how you prevent a feature branch from assuming the prod role.

Consume it in the job

apply-prod:
  id_tokens:
    VAULT_ID_TOKEN:
      aud: https://vault.example.com
  variables:
    VAULT_ADDR: https://vault.example.com
  script:
    - export VAULT_TOKEN="$(vault write -field=token auth/jwt/login role=ansible-prod jwt=$VAULT_ID_TOKEN)"
    - export API_PASSWORD="$(vault kv get -field=password secret/prod/app/db)"
    - ansible-playbook -i inventories/prod site.yml
  after_script:
    - vault token revoke -self || true

AWS via OIDC

Create the IAM identity provider

aws iam create-open-id-connect-provider \
  --url https://gitlab.example.com \
  --client-id-list sts.amazonaws.com \
  --thumbprint-list "$(openssl s_client -servername gitlab.example.com -connect gitlab.example.com:443 < /dev/null 2>/dev/null \
    | openssl x509 -fingerprint -noout -sha1 | cut -d= -f2 | tr -d :)"

A role the job can assume

{
  "Version": "2012-10-17",
  "Statement": [{
    "Effect": "Allow",
    "Principal": { "Federated": "arn:aws:iam::123456789012:oidc-provider/gitlab.example.com" },
    "Action": "sts:AssumeRoleWithWebIdentity",
    "Condition": {
      "StringEquals": {
        "gitlab.example.com:aud": "sts.amazonaws.com"
      },
      "StringLike": {
        "gitlab.example.com:sub": "project_path:infra/terraform:ref_type:branch:ref:main"
      }
    }
  }]
}

The aud must match the aud you asked GitLab to sign. The sub is how you restrict to a project/branch. Use StringLike with * carefully — too broad is the whole point of getting wrong.

Consume in the job

terraform-apply:
  id_tokens:
    AWS_ID_TOKEN:
      aud: sts.amazonaws.com
  variables:
    AWS_WEB_IDENTITY_TOKEN_FILE: /tmp/aws.token
    AWS_ROLE_ARN: "arn:aws:iam::123456789012:role/gitlab-terraform-prod"
    AWS_DEFAULT_REGION: eu-west-1
  script:
    - echo -n "$AWS_ID_TOKEN" > /tmp/aws.token
    - aws sts get-caller-identity
    - terraform apply -auto-approve

No AWS_ACCESS_KEY_ID, no AWS_SECRET_ACCESS_KEY. The AWS CLI and SDKs read AWS_WEB_IDENTITY_TOKEN_FILE automatically.

GCP via OIDC

Workload Identity Pool

gcloud iam workload-identity-pools create "gitlab-pool" \
  --location="global" --display-name="GitLab CI"

gcloud iam workload-identity-pools providers create-oidc "gitlab" \
  --location="global" \
  --workload-identity-pool="gitlab-pool" \
  --issuer-uri="https://gitlab.example.com" \
  --attribute-mapping="google.subject=assertion.sub,attribute.project_path=assertion.project_path,attribute.ref_protected=assertion.ref_protected" \
  --attribute-condition="assertion.project_path=='infra/terraform' && assertion.ref_protected=='true'"

Bind the pool to a service account

gcloud iam service-accounts add-iam-policy-binding \
  ci-deploy@proj.iam.gserviceaccount.com \
  --role="roles/iam.workloadIdentityUser" \
  --member="principalSet://iam.googleapis.com/projects/12345/locations/global/workloadIdentityPools/gitlab-pool/attribute.project_path/infra/terraform"

Consume in the job

gcp-deploy:
  id_tokens:
    GCP_ID_TOKEN:
      aud: https://iam.googleapis.com/projects/12345/locations/global/workloadIdentityPools/gitlab-pool/providers/gitlab
  script:
    - echo "$GCP_ID_TOKEN" > /tmp/gcp.token
    - |
      cat > /tmp/gcp-sa.json <<JSON
      {
        "type": "external_account",
        "audience": "//iam.googleapis.com/projects/12345/locations/global/workloadIdentityPools/gitlab-pool/providers/gitlab",
        "subject_token_type": "urn:ietf:params:oauth:token-type:jwt",
        "token_url": "https://sts.googleapis.com/v1/token",
        "service_account_impersonation_url": "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/ci-deploy@proj.iam.gserviceaccount.com:generateAccessToken",
        "credential_source": { "file": "/tmp/gcp.token" }
      }
      JSON
    - export GOOGLE_APPLICATION_CREDENTIALS=/tmp/gcp-sa.json
    - gcloud auth login --cred-file=$GOOGLE_APPLICATION_CREDENTIALS

Rotation and revocation

What to do if a secret ends up in a log

  1. Rotate the credential now. Do not wait to investigate.
  2. Delete the job log: API DELETE /projects/:id/jobs/:job_id/artifacts, then UI "Erase job log" on the job.
  3. If it was on a merge commit, rewriting history does not help — assume the secret is compromised.
  4. Open a ticket; add a mask rule if the value has a recognisable shape; add a CI check that greps for common credential formats.

include, cross-project, and inherited secrets

When using include:project: to pull in a CI template from another project, variables defined in the template project do not come with it. Only the importing project's variables apply. That is almost always what you want — otherwise a library author could demand your prod secrets.

include:
  - project: 'infra/ci-templates'
    ref: v3.2.0                  # pin, never 'main'
    file: '/ansible/lint.yml'

Pin the ref to a tag. main is a moving target that a compromise of the template repo turns into a pipeline takeover of every consumer.

Troubleshooting

SymptomCauseFix
invalid audience / aud mismatchThe aud requested in id_tokens doesn't match what Vault/AWS expects.Ensure both sides have the exact same string, including scheme and trailing slash (or lack thereof).
invalid claim: subThe role's bound_claims.sub or IAM StringLike:sub does not match the job's JWT.Print $VAULT_ID_TOKEN payload (base64 decode the middle segment) in a non-prod job once, eyeball the sub.
Vault: server_offset / clock skewGitLab and Vault clocks differ by >60s; JWT's nbf is in the future.Run chrony/ntp on both; GitLab runner clocks often drift in VMs.
Variable value has a $, job fails to substitute"Expand variable reference" is on by default.Uncheck "Expand variable reference" on that variable, or escape as $$.
Masking didn't redact the secretValue too short, multiline, or has disallowed characters.Move to a file variable; re-generate the credential to fit the masking regex.
Secret visible in env outputThe job intentionally or accidentally ran env/set with secrets in scope.Never run env on a job with secrets. Rotate if it already happened.
Feature branch can run apply:prodEnvironment not marked "protected", or runner is not ref_protected.Protect the environment; re-register runner with --access-level=ref_protected.
OIDC works on main but not on tag pipelinesClaim bound on ref_type=branch.Add ref_type=tag as an allowed value, or separate role.

See also: HashiCorp Vault, GitLab CI/CD, CI for Ansible.