TTerramantle
Get Started Free

Publishing Terraform modules with OIDC, no long-lived tokens

Somewhere in your CI there is probably a registry token. It was created two years ago by someone who has since left, it lives in an encrypted secret nobody rotates, and it can publish to your module registry from any workflow that can read it. If that token leaks, whoever has it can push a module version that every consumer's terraform init will happily pull.

OIDC removes the token. Not rotates it, removes it. There is nothing stored to leak. This post is how that works, why it is the same handshake you already use for AWS if you have set that up, and a workflow you can copy.

How the OIDC token actually works

Most people cargo-cult the YAML without understanding what the token is, so here is the mental model that makes the rest obvious.

Every GitHub Actions run can ask GitHub's OIDC provider to mint a short-lived, signed JWT. That token is not a secret you stored. It is minted fresh for that run, and it carries claims describing exactly where it came from: the repository, the branch or tag, the environment, the workflow file. It expires in minutes. A service that receives it verifies the signature against GitHub's public keys, then checks those claims against a trust policy before granting anything.

The mental model to land: the token is proof that "this specific workflow, in this specific repo, is running right now." It is not a stored credential. Nothing sits in encrypted variables waiting to be stolen, because there is nothing to store. The proof is generated on demand and thrown away.

That is the whole idea. Everything below is plumbing.

The AWS parallel you already know

If you have wired GitHub Actions to AWS without static keys, you have already done this exact handshake, just pointed at a different service. It is worth drawing the parallel because it makes the registry side feel familiar instead of new.

In AWS you register GitHub as an OIDC identity provider in IAM, then write a role trust policy that matches the sub and aud claims (this repo, this branch). The workflow calls sts:AssumeRoleWithWebIdentity with its OIDC token, IAM verifies the signature and the claims, and hands back temporary credentials. Publishing to a registry over OIDC is the same handshake with a different audience and a different thing granted at the end.

GitHub-to-AWSGitHub-to-registry
IAM OIDC identity providerRegistry trusts GitHub's issuer
IAM role trust policy (repo, branch claims)Registry trust rule (repo subject glob)
sts:AssumeRoleWithWebIdentityRegistry verifies the token directly
aud = your configured audienceaud = https://registry.terramantle.dev
sub = repo:org/repo:ref:...same sub, matched against the trust rule
Result: temporary AWS credentialsResult: a scoped publish for that run

One difference worth knowing. The AWS flow exchanges the OIDC token for temporary credentials via STS. Terramantle skips the exchange: the workflow presents the OIDC token directly as a bearer token on the publish request, and the registry verifies it in place. Fewer moving parts, same trust model.

Setting up the trust rule

Before a workflow can publish, you tell the registry which repo and ref to trust. That is a trust rule keyed on the token's sub claim, which for GitHub looks like repo:acme/terraform-aws-vpc:ref:refs/tags/v1.2.0. You can match exactly or with a glob, for example trust any tag on that repo but not branch pushes. When two rules could match, the more specific one wins, and an ambiguous match across orgs fails closed rather than picking one.

You set this up once per repo in the registry, in the pipeline-trust settings. Nothing about it is a secret.

The worked GitHub Actions workflow

Here is a full publish job, tag-triggered, with the load-bearing lines called out.

name: publish-module
on:
  push:
    tags: ['v*']

permissions:
  id-token: write   # REQUIRED. Lets the job mint an OIDC token.
  contents: read    # Read the repo to build the archive.

jobs:
  publish:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Derive version from tag
        id: v
        run: echo "version=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT"

      - name: Mint OIDC token for the registry
        id: oidc
        uses: actions/github-script@v7
        with:
          script: |
            const token = await core.getIDToken('https://registry.terramantle.dev')
            core.setOutput('token', token)

      - name: Package and publish
        env:
          TOKEN: ${{ steps.oidc.outputs.token }}
          VERSION: ${{ steps.v.outputs.version }}
        run: |
          tar czf module.tar.gz --exclude .git --exclude .terraform .
          curl -sf -X PUT \
            -H "Authorization: Bearer $TOKEN" \
            --data-binary @module.tar.gz \
            "https://registry.terramantle.dev/v1/modules/acme/vpc/aws/$VERSION"

The two lines that matter most:

permissions: id-token: write is the one everyone forgets. Without it the job cannot mint a token at all, and the getIDToken call fails before you ever reach the registry. Default permissions do not include it, so it must be set.

core.getIDToken('https://registry.terramantle.dev') mints the token with the registry as its audience. That string has to match what the registry expects, exactly. It is not a URL that gets fetched, it is an identifier both sides agree on.

The publish itself is a plain PUT of the tarball to /v1/modules/:namespace/:name/:provider/:version, with the freshly minted token as a bearer header. No stored secret anywhere in the job.

GitLab CI, briefly

GitLab has the same capability under a different keyword. You declare an ID token with the registry as its audience and it appears as an environment variable:

publish:
  id_tokens:
    REGISTRY_TOKEN:
      aud: https://registry.terramantle.dev
  script:
    - tar czf module.tar.gz --exclude .git .
    - |
      curl -sf -X PUT \
        -H "Authorization: Bearer $REGISTRY_TOKEN" \
        --data-binary @module.tar.gz \
        "https://registry.terramantle.dev/v1/modules/acme/vpc/aws/$CI_COMMIT_TAG"

The registry recognises GitLab's issuer the same way it recognises GitHub's, and the trust rule matches GitLab's project_path subject instead of GitHub's repo: one.

Version tags

Tag your releases vX.Y.Z and strip the v for the registry, which is what ${GITHUB_REF_NAME#v} does above. The registry wants a bare SemVer version. Keep the repo named terraform-PROVIDER-NAME so the module address derives cleanly, which is its own naming rabbit hole.

The three errors everyone hits

No token gets minted. Symptom: the getIDToken step fails, or the token is empty, and you never reach the registry. Cause: missing permissions: id-token: write. This one is on GitHub's side, not the registry's, which is why the registry error logs show nothing. Add the permission.

Audience mismatch. Symptom: the registry rejects the request with a 401 and "OIDC token audience mismatch." Cause: the audience you minted the token with does not match what the registry expects. Check the string in getIDToken(...) against the registry's configured audience, character for character.

Subject not trusted. Symptom: a 401 with "OIDC token not trusted for any org." The token was valid and correctly signed, but its sub claim did not match any trust rule. Cause: the trust rule does not cover this repo or this ref, for example you set it up for tags but pushed from a branch. Fix the rule or the trigger.

None of these need a secret rotated, because there is no secret. That is the point.

Sources

Verified resolving at time of writing:

This works against the Terramantle beta today. If you are wondering why a registry that publishes over OIDC does not also run your Terraform for you, that is deliberate.

Last reviewed

Terramantle is a private Terraform and OpenTofu registry, in beta. See pricing or read the FAQ.