GitLab Token Rotation Playbook
Token types in use
| Prefix | Type | Rotation method |
|---|---|---|
glpa- | Personal Access Token (PAT) | Tier 1: API or GitLab UI |
glcbt- | CI Job Token (ephemeral) | Automatic — expires when CI job ends; no manual rotation |
gldt- | Deploy Token | Tier 2: GitLab API (DELETE /api/v4/projects/:id/deploy_tokens/:id) |
glptt- | Project Access Token | Tier 1: API (DELETE /api/v4/projects/:id/access_tokens/:id) |
Where GitLab credentials live in p24-infra
| Key | SOPS file | Where used |
|---|---|---|
GITLAB_ADMIN_PAT | secrets/administration.env.sops | API operations, CI management |
CI clone tokens (glcbt-) are ephemeral — valid only for one CI job run. If found hardcoded in config files, they are almost certainly already expired.
Rotating GITLAB_ADMIN_PAT (glpa- prefix)
Tier check
GITLAB_ADMIN_PAT is a Personal Access Token — Tier 1 (can rotate via GitLab API or UI).
Via GitLab API (preferred) — through the SOPS broker (#5298, ADR 003)
Preferred (Windows dev / secret-manager session): use the sops-invoke.ps1
broker so GITLAB_ADMIN_PAT is decrypted inside the broker’s own process,
exposed only as $env:GL_TOKEN for the wrapped command, and redacted out of the
command’s output. Never hand-roll a sops -d | grep | cut → $GL_TOKEN → curl
extraction — that ad-hoc shape is the exact class behind every historical leak
(#2040/#3545/#3714/#5223). See docs/adr/003-sops-secret-access-broker.md.
# Create a new PAT for the same user. The broker holds the token; the command
# references it only as $env:GL_TOKEN and the response is auto-redacted.
.\scripts\sops-invoke.ps1 -SopsFile secrets\administration.env.sops `
-Keys '{"GL_TOKEN":"GITLAB_ADMIN_PAT"}' `
-Command 'curl -s -X POST "https://gitlab.com/api/v4/users/<USER_ID>/personal_access_tokens" -H "PRIVATE-TOKEN: $env:GL_TOKEN" -H "Content-Type: application/json" -d ''{"name":"p24-infra-admin-20260803","scopes":["api","read_user","read_repository","write_repository"]}'''
# Revoke the old token by ID (same broker pattern):
.\scripts\sops-invoke.ps1 -SopsFile secrets\administration.env.sops `
-Keys '{"GL_TOKEN":"GITLAB_ADMIN_PAT"}' `
-Command 'curl -s -X DELETE "https://gitlab.com/api/v4/personal_access_tokens/<OLD_ID>" -H "PRIVATE-TOKEN: $env:GL_TOKEN"'The new token value is returned by the API only once. When you write it into SOPS, pipe it through
sops-set.ps1($env:NEW_VALUE) — never print it.⚠️ Capture and persist in the SAME command (issue #5717). GitLab also exposes a one-call self-rotate endpoint,
POST /personal_access_tokens/self/rotate(rotates the calling token, invalidating the old value immediately and returning the new one) — simpler than the create+delete flow above, but the exact same rule applies: capture$resp.tokenand hand it tosops-set.ps1inside the same PowerShell call that made the rotate request. Windows tool calls do not retain shell state (env vars, local variables) between separate invocations — a rotate-then-save-later split loses the new value the instant the rotate call’s process exits, with no way to recover it (GitLab shows the value exactly once). This is exactly howGITLAB_ADMIN_PATgot locked out end-to-end on 2026-08-06 (#5717): the rotate call succeeded, only itsid/expires_atwere printed, and a later call to persist it found the SOPS-stored (pre-rotation) value already invalidated — 401 with no path back in short of a human re-issuing a token from the GitLab UI (creation via API is blocked on GitLab.com SaaS — rotation of an existing token is the only API path in or out). Also useInvoke-RestMethod -Headers @{...}(a typed hashtable) for every GitLab API call in PowerShell —curl -H "..."binds to theInvoke-WebRequestalias, which rejects-Hwith an exception that embeds the literal expanded header (token included) in its message; seestatic-api-key-incident-rotation.md’s FORBIDDEN list for the same #5717 finding.
Fallback (bash/Linux workers, until the bash broker sibling lands — ADR 003 §Known gap): the documented safe-extraction pattern is still permitted where the PowerShell broker is unavailable:
GL_TOKEN=$(sops -d --input-type dotenv --output-type dotenv secrets/administration.env.sops | grep '^GITLAB_ADMIN_PAT=' | cut -d= -f2-)
curl -s -H "PRIVATE-TOKEN: $GL_TOKEN" https://gitlab.com/api/v4/user >/dev/null
unset GL_TOKENUI-only case: if the API path is unavailable, use the Playwright carve-out —
docs/playbooks/gitlab-token-playwright-broker.md.
Distribution chain after rotation
- Update
secrets/administration.env.sops(GITLAB_ADMIN_PAT) via/role-secret-manager - Confirm no other files reference the old token:
grep -r "glpa-" secrets/ 2>/dev/null # should be empty - Update GH Secrets if CI uses this token:
gh secret set GITLAB_ADMIN_PAT --repo radieu/p24-infra - Log rotation in
docs/playbooks/secret-rotation-log.md
Handling exposed glcbt- (CI Job Token) incidents
CI Job Tokens are automatically invalidated when the pipeline job ends. If one was exposed:
- Assess timeline: when did the CI job that generated this token run?
- If the job finished → token is already invalid. Risk = zero.
- Check if hardcoded:
grep -r 'glcbt-' /etc/ /root/ 2>/dev/null- If found: remove from files, restart affected services
- Create GH issue with label
security+human-action(Tier 3 if bms-1 UI config) - No emergency rotation needed unless token is confirmed still active
Confirming a glcbt- token is still active
# Returns 200 if active, 401/403 if expired
curl -s -o /dev/null -w "%{http_code}" \
"https://gitlab.com/api/v4/projects/<ID>/repository/branches" \
-H "PRIVATE-TOKEN: glcbt-6b_..."Deploy Token rotation (gldt-)
# List deploy tokens
curl -s "https://gitlab.com/api/v4/projects/<ID>/deploy_tokens" \
-H "PRIVATE-TOKEN: $GITLAB_ADMIN_PAT"
# Revoke old
curl -s -X DELETE "https://gitlab.com/api/v4/projects/<ID>/deploy_tokens/<TOKEN_ID>" \
-H "PRIVATE-TOKEN: $GITLAB_ADMIN_PAT"
# Create new
curl -s -X POST "https://gitlab.com/api/v4/projects/<ID>/deploy_tokens" \
-H "PRIVATE-TOKEN: $GITLAB_ADMIN_PAT" \
-H "Content-Type: application/json" \
-d '{"name":"deploy-20260701","scopes":["read_repository"]}'Escalation
If GITLAB_ADMIN_PAT itself is expired or revoked:
- Log into gitlab.com with the
radieuaccount (human) - Create new PAT at: User settings → Access Tokens
- Update SOPS via
/role-secret-manager
Audit Log — Log to infra_operations
After this operation completes, log it to the infra_operations audit table.
Python (Linux server — bms-4, vps-i1, vps-h1, or similar):
import sys
sys.path.insert(0, '/opt/p24-infra')
from scripts.lib.log_op import log_op
log_op(
actor="claude", # "radieu" for manual human ops, "claude" for agent
op_type="credential_rotation",
resource="GITLAB_TOKEN",
result="success", # "success" | "failed" | "skipped"
detail="Scheduled rotation — GitLab access token regenerated and SOPS updated",
env="bms-4",
gh_issue=2730,
)PowerShell (Windows dev machine):
$env:SUPABASE_URL = (Get-Content "C:\code_2026\p24-infra\.env.local" | Select-String "^SUPABASE_URL=").ToString().Split("=",2)[1].Trim()
$env:SUPABASE_SERVICE_KEY = (Get-Content "C:\code_2026\p24-infra\.env.local" | Select-String "^SUPABASE_SERVICE_KEY=").ToString().Split("=",2)[1].Trim()
python -c "
import os, sys
sys.path.insert(0, 'C:/code_2026/p24-infra')
from scripts.lib.log_op import log_op
log_op('claude', 'credential_rotation', 'GITLAB_TOKEN', 'success', 'Scheduled rotation — GitLab access token regenerated and SOPS updated', 'bms-4')
"
$env:SUPABASE_URL = ''; $env:SUPABASE_SERVICE_KEY = ''