Configuring OIDC is the easy part. Removing the credentials it replaces is the part that gets deferred — and a repository with both a working OIDC role and an unused access key still in secrets has gained nothing in security terms. The old credential is what an attacker would use.
This page is the migration, including the step people skip: proving the old path is dead before deleting it, and deleting it afterwards.
Inventory first
Section titled “Inventory first”You cannot retire what you have not found. Credentials hide in more places than repository secrets:
gh secret list --repo OWNER/REPOgh secret list --org ORGgh variable list --repo OWNER/REPO
{/* Environment secrets are per environment and are missed by the commands above */}gh api repos/OWNER/REPO/environments --jq '.environments[].name' | while read -r env; do echo "== $env" gh api "repos/OWNER/REPO/environments/$env/secrets" --jq '.secrets[].name'doneNames that indicate a long-lived cloud credential:
| Name | What it is |
|---|---|
AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY | IAM user key — no expiry |
AZURE_CREDENTIALS | JSON blob containing a client secret |
AZURE_CLIENT_SECRET | Entra ID client secret |
GCP_SA_KEY / GOOGLE_CREDENTIALS | Service account key JSON — no expiry |
KUBECONFIG / KUBE_CONFIG_DATA | Cluster credentials, often with a long-lived token |
DOCKER_PASSWORD | Registry credential; GHCR needs none |
Also check for credentials outside GitHub entirely — a CI variable in another system, a key baked into
a container image, a .env in a deployment repository. The GitHub secret is often a copy.
Migrate one repository at a time
Section titled “Migrate one repository at a time”-
Configure OIDC alongside the existing credential. Create the identity provider, role or federated credential, and conditions — following AWS, Azure or Google Cloud. Nothing is removed yet.
-
Add a read-only verification job that authenticates via OIDC and reads something harmless —
aws sts get-caller-identity,az account show,gcloud auth list. Confirm it works from the exact trigger your real deployment uses, including the environment. -
Switch the real workflow to OIDC. Add
id-token: write, replace the credential step, and leave the old secret in place. -
Run it for real. Deploy through the new path and confirm the deployment behaves identically.
-
Prove the old credential is unused. Check the cloud’s last-used data after a period covering your full cycle — including weekly or monthly scheduled workflows, which are exactly what a two-day observation window misses.
-
Disable, do not delete. Deactivate the access key, disable the client secret, or disable the service account key. If something breaks, re-enabling takes seconds.
-
Wait, then delete. After a period with nothing broken, delete the cloud credential and remove the GitHub secret.
Step 5 is the one that determines whether this migration is safe, and step 6 is the one that makes it reversible. Skipping to deletion is how a migration becomes an outage.
Verifying the old path is really dead
Section titled “Verifying the old path is really dead”AWS — the access key’s last-used date and the service it was used against:
aws iam list-access-keys --user-name ci-deployaws iam get-access-key-last-used --access-key-id AKIA_PLACEHOLDERAzure — sign-in activity for the service principal is in the Entra ID sign-in logs, filtered to service principal sign-ins. Also list credentials on the app registration to confirm what still exists:
az ad app credential list --id APP_ID --query "[].{name:displayName, end:endDateTime}" -o tableGoogle Cloud — key usage is reported per service account key, and IAM’s activity analyser reports recent authentication.
In all three, “no usage since we cut over” is the signal to proceed. “No usage ever” on a key that exists usually means you have found a second, forgotten credential — which is its own finding.
Disabling before deleting
Section titled “Disabling before deleting”{/* AWS: deactivate, keep the key so it can be re-enabled */}aws iam update-access-key --user-name ci-deploy --access-key-id AKIA_PLACEHOLDER --status Inactive{/* Google Cloud: disable the key */}gcloud iam service-accounts keys disable KEY_ID --iam-account=deployer@PROJECT_ID.iam.gserviceaccount.comFor Azure, remove the client secret from the app registration — there is no disable, so record what you removed before doing it.
Then delete the GitHub secret:
gh secret delete AWS_SECRET_ACCESS_KEY --repo OWNER/REPOWhat cannot be migrated
Section titled “What cannot be migrated”Some things genuinely still need a stored secret:
- Vendors that only issue API keys.
- Azure Static Web Apps deployment tokens.
- Docker Hub, and other registries without federation.
- Signing keys held outside a managed service.
For each, reduce the blast radius rather than pretending it is fine:
- Store it as an environment secret behind protection rules, not as a repository secret. It is then unreadable by any job that has not passed the environment’s gate.
- Scope it as narrowly as the vendor allows — one repository, one permission, read-only where possible.
- Set a rotation reminder and actually rotate.
- Prefer a GitHub App token over a personal access token where the counterparty is GitHub —
actions/create-github-app-token@v3mints one per run, scoped and short-lived.
Preventing regression
Section titled “Preventing regression”Migrating and then having someone add AWS_SECRET_ACCESS_KEY back next quarter is a common outcome.
Two controls help:
Audit on a schedule. A workflow that lists secrets across the organisation and fails on names matching known long-lived credential patterns turns regression into a build failure.
Remove the IAM user entirely. If the user that owned the access key no longer exists, a new key cannot be created for it by habit. This is more durable than any policy document.
Exercise
Section titled “Exercise”-
Inventory every secret across your repositories, including environment secrets, and classify each as long-lived or not.
-
Pick the highest-risk one — usually a cloud key with broad permissions — and check its last-used date.
-
Configure OIDC alongside it and add a read-only verification job. Confirm it works from the real trigger.
-
Cut over the deployment. Observe for a period covering your scheduled workflows.
-
Deactivate — not delete — the old credential. Wait. Then delete it and remove the GitHub secret.
-
Delete the IAM user or service account that owned it, so the habit cannot be repeated.