Removing the secret is only half the OIDC migration


A GitHub Actions workflow can stop referring to a reusable credential and still be unable to request its replacement.

That is the awkward part of moving the Tailscale GitHub Action to workload identity federation. The old credential has to disappear, but the workflow also needs the inputs and permission that let it obtain a short-lived token. A partial migration can leave the old secret in place, mix two credential families, or produce a workflow that looks secretless and cannot authenticate.

I built tailscale-oidc-preflight to check that workflow-side change without reading secret values or contacting GitHub or Tailscale.

One reusable key enrolled 181 nodes

In Tailscale’s account of the Hugging Face intrusion, an escaped agent reached a production secret store containing 136 keys. One was a reusable Tailscale auth key used by CI. The agent copied it into external sandboxes and enrolled 181 nodes in the Hugging Face tailnet over several days. Each node received the identity tag and access assigned to a CI node.

Tailscale says no vulnerability in its software was found or exploited. The reusable credential worked as configured, including after it was copied out of CI and used to enroll a node somewhere else.

Workload identity federation changes the credential source. A GitHub Actions job asks GitHub for a signed OIDC token. Tailscale verifies the issuer, audience, expiry, and configured claim rules, then returns a short-lived token with the scopes assigned to that workload. The trust is tied to claims about the running job rather than a reusable key stored beside the workload.

The setup crosses two systems, though. A tailnet administrator configures the issuer, subject and claim rules, audience, scopes, and tags in Tailscale. The repository workflow supplies the federated client ID, audience, and tags, then grants id-token: write so the job can ask GitHub for its OIDC token.

Deleting authkey changes only one part of that arrangement.

The permission is part of the credential path

The old workflow can be as small as this:

jobs:
  connect:
    runs-on: ubuntu-latest
    steps:
      - uses: tailscale/github-action@v4
        with:
          authkey: ${{ secrets.TAILSCALE_AUTHKEY }}

The OIDC form needs different action inputs and a GitHub permission:

permissions:
  id-token: write
  contents: read

jobs:
  connect:
    runs-on: ubuntu-latest
    steps:
      - uses: tailscale/github-action@v4
        with:
          oauth-client-id: ${{ secrets.TS_OAUTH_CLIENT_ID }}
          audience: ${{ secrets.TS_AUDIENCE }}
          tags: tag:ci

id-token: write does not give the job write access to repository contents. It lets the job request GitHub’s OIDC token. Without it, the Tailscale action has the new inputs but cannot obtain the token it needs.

The effective permission can also differ from the one near the top of the file. Under GitHub’s workflow permission rules, a job-level permissions block becomes the permission set for that job, and omitted permissions are set to none. A workflow may declare id-token: write globally and then remove it accidentally when the Tailscale job defines its own narrower permissions.

The failure that bothers me most contains every string a quick search would look for: audience:, id-token: write, and the Tailscale action. The job-level permission block can still make the token unavailable to that action.

Check the whole workflow slice

The preflight reads workflow YAML and finds static tailscale/github-action@... steps. For each step it checks for:

  • an authkey or oauth-secret input;
  • mixed legacy and OIDC credential inputs;
  • nonempty oauth-client-id, audience, and tags inputs;
  • effective id-token: write permission after job-level overrides.

It never includes values from the with: block in its output. The finding names the input key, workflow path, job, and step number. Running the released wheel against the public before-migration fixture gives:

$ tailscale-oidc-preflight tests/fixtures/before-authkey.yml
tests/fixtures/before-authkey.yml: job connect, step 1: AUTHKEY_INPUT: input key 'authkey' is not OIDC
tests/fixtures/before-authkey.yml: job connect, step 1: INCOMPLETE_OIDC: missing or empty input keys: oauth-client-id, audience, tags
tests/fixtures/before-authkey.yml: job connect, step 1: ID_TOKEN_PERMISSION: effective id-token permission is not write

The corresponding OIDC fixture passes. A repository path scans only its .github/workflows tree, so the same command can run locally or in CI:

$ tailscale-oidc-preflight .
PASS: all Tailscale GitHub Action steps use complete OIDC

Exit 1 means the checker understood the workflow and found an insecure or incomplete Tailscale step. Exit 2 means it could not check confidently. Malformed YAML, duplicate mapping keys, unreadable input, invalid workflow structure, and relevant dynamic expressions fail closed instead of quietly producing a pass.

A migration gate cannot use “no problem found” to mean “the parser skipped the part it did not understand.”

A pass does not validate the trust relationship

The checker sees one side of the exchange: repository workflow files. It cannot inspect the federated identity configured in Tailscale. It does not verify the issuer, audience registration, subject or custom claim rules, auth_keys scope, tag ownership, or access rules. It does not request a token, prove an exchange succeeds, or show what the resulting node can reach.

A workflow pass therefore needs a separate review of the Tailscale trust credential. The useful claim is narrow: the workflow no longer presents an auth key or OAuth secret to the Tailscale action, contains the required OIDC inputs, and gives that action effective permission to request a GitHub token.

There is also a documented exception. Tailscale’s GitHub Action currently requires an ephemeral reusable pre-signed auth key for a Tailnet Lock deployment. The checker reports that key because it is not OIDC; it cannot decide that the exception is wrong for the deployment.

I released version 0.1.0 with public before-and-after fixtures. Its 26 tests pass on Python 3.9 and 3.11. I installed the release wheel in a clean Python 3.9 environment, confirmed the OIDC fixture passes, and confirmed the auth-key fixture exits 1 without rendering its secret reference. The repository has no GitHub Actions runs, so those are local release checks rather than CI results.

Nobody has reported using the tool in a real migration yet. I have a small executable gate for the part that can be proved from workflow text. It checks that removing the reusable secret did not leave behind another reusable credential or a job with no way to obtain the replacement token.

Comments