Featured image of post Sign your own binaries with go-tool-base, part 3: keyless CI signing with OIDC

Sign your own binaries with go-tool-base, part 3: keyless CI signing with OIDC

Part 2 left you with a KMS key your release pipeline can sign through and a role (<name>-signer) that’s allowed to call kms:Sign and nothing else. There’s one obvious question left hanging: how does a CI job become that role without an AWS access key stashed in a CI variable? Because a long-lived key sitting in your settings is exactly the kind of credential that ends up in a breach write-up. This part wires CI in with no stored credentials at all, on GitLab and GitHub.

The mechanism is OIDC federation, and the one-sentence version is: your CI platform already proves who it is to AWS, so let it. When a pipeline runs, GitLab or GitHub can hand the job a short-lived signed token (a JWT) that says “this is a tag pipeline on acme/acme-cli”. AWS trusts that token the same way a bouncer trusts a passport: it checks who issued it and what it says, and if the claims match a role’s trust policy, it swaps the token for temporary AWS credentials that live only for the job’s run. No key is stored anywhere; the credentials are minted on the spot and evaporate when the runner stops. The deep-dive on keyless CI covers the why and the threat model; here we just do the wiring.

Two things have to line up for this to work: the IAM OIDC identity provider (the thing in your AWS account that says “I trust tokens from this issuer”), and the signer role’s trust policy (which says “and only from these pipelines”). The terraform-aws-signing-kms module owns the second. The first comes from a sibling module.

Where the identity provider comes from

You don’t register the OIDC provider in the signing module. That’s deliberate: the same provider is shared across every role in the account that federates from CI, so it lives one level up, in terraform-aws-bootstrap. It takes a ci_provider input (github by default, or gitlab), registers the right IAM OIDC identity provider for that forge, and emits its ARN. You feed that ARN into the signing module. If you ran the bootstrap in Part 2 you already have it; if not, stand it up first and grab the oidc_provider_arn output.

That ARN is the only thing the two modules need to agree on. Everything else about which pipelines may sign lives in the signing module’s trust policy, which is what the rest of this part configures.

The GitLab path

Look the provider up by URL so you never hardcode the ARN, then pass it in along with the subject filter that scopes who can assume the role:

data "aws_iam_openid_connect_provider" "gitlab" {
  url = "https://gitlab.com"
}

module "signing_kms" {
  source  = "gitlab.com/phpboyscout/signing-kms/aws"
  version = "0.1.2"

  name              = "acme-release-signing-v1"
  oidc_provider_arn = data.aws_iam_openid_connect_provider.gitlab.arn

  # oidc_issuer_host defaults to "gitlab.com"
  # oidc_audience    defaults to "sts.amazonaws.com"

  ci_subject_filters = [
    "project_path:acme/acme-cli:ref_type:tag:ref:v*",
  ]

  key_administrator_arns = [/* ... from Part 2 ... */]
  automation_role_arn    = data.aws_iam_role.automation.arn
}

The interesting line is ci_subject_filters. GitLab stamps each CI token’s sub claim with the project path, the ref type and the ref. The pattern above reads as “tag pipelines on acme/acme-cli, for any ref starting v”. A branch pipeline or a merge-request pipeline carries ref_type:branch instead, so it simply doesn’t match, and the role refuses to be assumed. Your signer can only be driven from a release tag, which is the whole point: a dependency author opening an MR can’t trick CI into minting a signature.

On the pipeline side, the release job declares an id_tokens block so GitLab issues a token with the right audience, writes it to a file, and the AWS SDK picks it up:

goreleaser:
  rules:
    - if: '$CI_COMMIT_TAG =~ /^v\d+\.\d+\.\d+$/'
  id_tokens:
    AWS_WEB_IDENTITY_TOKEN:
      aud: sts.amazonaws.com
  variables:
    AWS_REGION: eu-west-2
    AWS_ROLE_ARN: ${SIGNER_ROLE_ARN}
    AWS_WEB_IDENTITY_TOKEN_FILE: /tmp/aws-token
  before_script:
    - echo "$AWS_WEB_IDENTITY_TOKEN" > /tmp/aws-token
  script:
    - aws sts get-caller-identity
    - goreleaser release --clean

AWS_ROLE_ARN plus AWS_WEB_IDENTITY_TOKEN_FILE is the convention the SDK recognises: it sees the two together and calls assume-role-with-web-identity for you, so by the time goreleaser runs it’s already the signer role. The actual signing job is Part 6; the aws sts get-caller-identity line is just a sanity check that federation worked. It should print the signer role’s ARN.

The GitHub path

Same shape, different issuer and a different sub format. GitHub’s sub support landed in module v0.1.2: earlier versions validated ci_subject_filters against GitLab’s format only and would reject a GitHub subject outright, so pin the version.

data "aws_iam_openid_connect_provider" "github" {
  url = "https://token.actions.githubusercontent.com"
}

module "signing_kms" {
  source  = "gitlab.com/phpboyscout/signing-kms/aws"
  version = "0.1.2"

  name              = "acme-release-signing-v1"
  oidc_provider_arn = data.aws_iam_openid_connect_provider.github.arn
  oidc_issuer_host  = "token.actions.githubusercontent.com"

  # oidc_audience still defaults to "sts.amazonaws.com"

  ci_subject_filters = [
    "repo:acme/acme-cli:ref:refs/tags/v*",
  ]

  key_administrator_arns = [/* ... */]
  automation_role_arn    = data.aws_iam_role.automation.arn
}

Two differences from GitLab. The oidc_issuer_host has to change, because it’s the prefix on the trust-policy condition keys (token.actions.githubusercontent.com:sub rather than gitlab.com:sub). And the subject format is GitHub’s own: repo:<owner>/<repo>:ref:refs/tags/v* scopes the same way the GitLab pattern did, to tag refs only. The audience stays sts.amazonaws.com, because that’s the default aws-actions/configure-aws-credentials requests, so there’s nothing to override.

The workflow side is the official AWS action. It needs id-token: write permission to ask GitHub for the token in the first place, and contents: write so GoReleaser can create the release:

permissions:
  id-token: write
  contents: write
jobs:
  release:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: ${{ secrets.SIGNER_ROLE_ARN }}
          aws-region: eu-west-2
          audience: sts.amazonaws.com
      - run: aws sts get-caller-identity
      - run: goreleaser release --clean

If you forget id-token: write, GitHub never issues a token and the action fails before it reaches AWS. It’s the most common GitHub-side trip-up.

The gotcha that wastes an afternoon: token audience

Here’s the one worth burning into memory, because the error message points you at the wrong thing. An IAM OIDC provider carries a client_id_list, and it rejects any token whose aud claim isn’t on that list before AWS even looks at the role’s trust policy. So if your CI token’s audience and the provider’s client ID don’t match, you get:

InvalidIdentityToken: Incorrect token audience

That reads like a role-trust problem, and you’ll go round in circles editing ci_subject_filters, but the trust policy was never consulted. The fix is to keep the audience aligned to sts.amazonaws.com everywhere: it’s the module’s oidc_audience default, it’s what terraform-aws-bootstrap puts on the provider’s client_id_list, it’s the aud in the GitLab id_tokens block, and it’s the default aws-actions/configure-aws-credentials requests. Leave all four alone and they agree by default. The moment you override one, override all of them, or you’ll meet that error.

Where this leaves you

There’s now no AWS access key anywhere in either forge. The pipeline proves it’s a release tag on your project, AWS hands it the signer role for the length of the run, and the credentials are gone the moment the runner stops. The signing module’s trust policy is the gate; the bootstrap module’s OIDC provider is the lock it hangs on. Both are verifiable in the public modules: terraform-aws-signing-kms for the role and terraform-aws-bootstrap for the provider.

The role can sign, but you still can’t verify anything yet, because nobody has the public half of that KMS key. Part 4 fixes that: gtb keys mint pulls the public key straight out of KMS, and gtb keys wkd publishes it somewhere the release platform can’t touch.

Built with Hugo
Theme Stack designed by Jimmy