TILLSECRETS · WORKLOAD IDENTITY

Sign in as what you are

Beta

A service token is a secret you have to store somewhere, and anyone who copies it can use it. A workload identity needs nothing stored. The CI job, function, pod or server proves what it is with the identity its platform already gives it. TillSecrets checks that proof against the conditions you set, then issues a ts_ token that expires within the hour, or within the time you choose.

01Platforms

What it trusts

Where it runsProofCondition that names it
GitHub ActionsThe job’s OIDC tokenrepository (owner/repo or owner/*), plus ref, environment or any claim
GitLab CIAn id_tokens tokenproject_path, plus ref, environment or any claim
Bitbucket PipelinesThe step’s OIDC tokenThe workspace (issuer) and repositoryUuid, plus branchName
CircleCIA token from circleci run oidc getThe organization (issuer) and project ID, plus the branch
BuildkiteA token from buildkite-agent oidc request-tokenorganization_slug, plus pipeline_slug and build_branch
SemaphoreThe job’s OIDC tokenThe organization (issuer) and prj_id, plus branch
HCP Terraform and Terraform EnterpriseA workload identity token for runsterraform_organization_name, plus the workspace, project or run phase
SpaceliftThe run’s OIDC tokenThe account (issuer) and the stack (callerId)
Google Cloud (Compute Engine, Cloud Run, Functions, GKE)The metadata server’s identity tokenThe service account’s numeric id (sub) or email
Azure (App Service, Functions, Container Apps, VMs)A managed identity tokenThe tenant (issuer), your app registration, and the identity’s object ID (oid)
AWS (EC2, ECS, EKS, Lambda)A signed sts:GetCallerIdentity requestA role or IAM user ARN with a literal account
Kubernetes (EKS, GKE, AKS, your own)A projected service account tokensub, for example system:serviceaccount:web:api
Fly.io MachinesThe Machines API’s OIDC tokenThe organization (issuer) and app_name
Vercel functions and buildsThe deployment’s OIDC tokenThe team (issuer) and project, plus environment
Deno DeployA token from @deno/oidcorg_slug, plus app_slug and context_name
ModalThe container’s identity tokenworkspace_id, plus app_name and environment_name
A server with no platform identityA challenge encrypted to its host keyThe host key itself
Any other OIDC issuer (Jenkins, Forgejo, your own IdP)A token from that issuerAt least one exact claim

Conditions compare claims in the token. * matches any run of characters, including /, and a dotted name reaches a nested claim. The condition that names your repository, organization, project, stack, workspace or principal must be exact: these platforms issue tokens to every customer, so a wildcard there is refused. Each identity is scoped like a service token: one project, optionally one environment, read or read-write, and optionally exactly the keys you list.

create
# GitHub Actions: only runs on main in acme/web (GitHub Enterprise: add --issuer)
tilldev secrets identities create --project web --env prod --github acme/web --branch main

# GitLab CI (add --issuer https://gitlab.example.com for self-managed)
tilldev secrets identities create --project web --env prod --gitlab acme/web --ref main

# Bitbucket Pipelines: workspace and repository UUID
tilldev secrets identities create --project web --env prod --bitbucket 'acme/{7c5a…}' --branch main

# CircleCI: organization ID and project ID
tilldev secrets identities create --project web --env prod --circleci 0f3e…/9a1b… --branch main

# Buildkite: organization and pipeline slugs
tilldev secrets identities create --project web --env prod --buildkite acme/deploy --branch main

# Semaphore: organization and project ID
tilldev secrets identities create --project web --env prod --semaphore acme/5d2c… --branch main

# HCP Terraform: organization and workspace (Terraform Enterprise: add --issuer)
tilldev secrets identities create --project web --env prod --terraform acme/prod-network

# Spacelift: account and stack ID
tilldev secrets identities create --project web --env prod --spacelift acme/prod-network

# A Google Cloud service account
tilldev secrets identities create --project web --env prod --google deploy@acme.iam.gserviceaccount.com

# An Azure managed identity: tenant, app registration, and the identity's object ID
tilldev secrets identities create --project web --env prod --azure 72f9…/0f8b… --principal 9c2e…

# An AWS role (any session of it) or IAM user
tilldev secrets identities create --project web --env prod --aws arn:aws:iam::123456789012:role/web

# A Kubernetes service account
tilldev secrets identities create --project web --env prod \
  --kubernetes https://oidc.eks.eu-west-1.amazonaws.com/id/EXAMPLE --service-account web/api

# A Fly.io app
tilldev secrets identities create --project web --env prod --fly acme/web

# A Vercel project (team issuer mode), production only
tilldev secrets identities create --project web --env prod --vercel acme/web --claim environment=production

# A Deno Deploy app
tilldev secrets identities create --project web --env prod --deno acme/web

# A Modal app: workspace ID and app name
tilldev secrets identities create --project web --env prod --modal ac-1a2b…/etl

# A server with no platform identity: its host key
tilldev secrets identities create --project web --env prod --machine --host-key @host-key.pub

# Any other OIDC issuer (Jenkins, Forgejo, your own IdP), with the claims to require
tilldev secrets identities create --project web --env prod --oidc https://jenkins.acme.dev/oidc --claim sub=deploy

Creating an identity, or changing what it trusts, waits for your approval. The same is available under Secrets → Workload identities in the dashboard.

02Setup

On the workload

Set TILLSECRETS_IDENTITY to the identity's id, or pass --identity. The CLI (secrets pull, secrets exec --env, secrets lease) and the Node SDK find the proof and trade it for a token. TILLSECRETS_TOKEN or --token takes precedence when both are set.

GitHub Actions

The job needs permission to ask for an OIDC token.

.github/workflows/deploy.yml
jobs:
  deploy:
    runs-on: ubuntu-latest
    permissions:
      id-token: write
      contents: read
    env:
      TILLSECRETS_IDENTITY: 3f1c…            # the identity id
    steps:
      - uses: actions/checkout@v4
      - run: npx -y @tillstack/cli secrets exec --env prod -- ./deploy.sh

GitLab CI

.gitlab-ci.yml
deploy:
  id_tokens:
    TILLSECRETS_ID_TOKEN:
      aud: tilldev:secrets:3f1c…             # the identity's audience
  variables:
    TILLSECRETS_IDENTITY: 3f1c…
  script:
    - npx -y @tillstack/cli secrets exec --env prod -- ./deploy.sh

Bitbucket Pipelines

Ask for a token with the identity's audience on each step that needs secrets. The repository UUID is under Repository settings → OpenID Connect.

bitbucket-pipelines.yml
pipelines:
  branches:
    main:
      - step:
          oidc:
            audiences:
              - tilldev:secrets:3f1c…       # the identity's audience
          script:
            - export TILLSECRETS_IDENTITY=3f1c…
            - npx -y @tillstack/cli secrets exec --env prod -- ./deploy.sh

CircleCI, Buildkite, Semaphore and Spacelift

Nothing to configure beyond the identity id. On CircleCI the CLI and SDK ask for a token with the identity's audience (circleci run oidc get), and on Buildkite they ask the agent (buildkite-agent oidc request-token). Semaphore gives every job a token, and Spacelift every run.

pipeline
# .circleci/config.yml, .buildkite/pipeline.yml, .semaphore/semaphore.yml, a Spacelift stack
env:
  TILLSECRETS_IDENTITY: 3f1c…
run: npx -y @tillstack/cli secrets exec --env prod -- ./deploy.sh

HCP Terraform

The first variable makes HCP Terraform issue a token with the identity's audience to every plan and apply. Anything the run executes, such as an external data source or a provisioner, can then use the CLI. For Terraform Enterprise, give the identity your instance's address with --issuer.

workspace variables
# Workspace (or variable set) environment variables
TFC_WORKLOAD_IDENTITY_AUDIENCE_TILLSECRETS = tilldev:secrets:3f1c…
TILLSECRETS_IDENTITY                     = 3f1c…

Azure

Azure managed identities get tokens for an app registration, not for an audience you choose. Make an app registration for TillSecrets (it needs no permissions or secrets), set it to issue version 2 tokens, and give its client ID when you create the identity. Pin the managed identity by its object (principal) ID; other identities in your tenant can ask for tokens for the same app registration.

terminal
# Once: the app registration named by the identity issues version 2 tokens
az ad app update --id 0f8b… --set api.requestedAccessTokenVersion=2

# App settings on the App Service, Function, Container App or VM
TILLSECRETS_IDENTITY=3f1c…
AZURE_CLIENT_ID=…            # only for a user-assigned managed identity

Vercel

Turn on OIDC federation with the Team issuer mode (project settings → Security → Secure backend access). In a function, the token is only on the request, so read secrets there, with a token asked for this identity's audience. Pin environment=production to keep preview deployments and local development tokens out.

app/api/route.ts
import { getVercelOidcToken } from '@vercel/oidc'
import { createClient } from '@tillstack/secrets-node'

const secrets = createClient({
  identity: '3f1c…',
  workload: { idToken: (audience) => getVercelOidcToken({ audience }) },
})

export async function GET() {
  const { secrets: s } = await secrets.pull()   // inside the request: that's where Vercel puts the token
  // …
}

Deno Deploy

main.ts
import { getIdToken } from 'jsr:@deno/oidc'
import { load } from 'npm:@tillstack/secrets-node'

await load({ identity: '3f1c…', workload: { idToken: getIdToken } })

Modal

Nothing to configure. Every container has a token in MODAL_IDENTITY_TOKEN. Pass TILLSECRETS_IDENTITY as a Modal secret and run the CLI in the image, or use the Node SDK.

Kubernetes

Project a service account token with the identity's audience. The SDK and CLI read /var/run/secrets/tilldev/token, or the path in TILLSECRETS_ID_TOKEN_FILE. The issuer is the cluster's service account issuer. If its discovery document isn't reachable from the internet, save the cluster's keys (kubectl get --raw /openid/v1/jwks) and pass them with --jwks @jwks.json.

pod.yaml
apiVersion: v1
kind: Pod
spec:
  serviceAccountName: api
  containers:
    - name: api
      image: ghcr.io/acme/api
      env:
        - name: TILLSECRETS_IDENTITY
          value: 3f1c…
      volumeMounts:
        - name: tilldev
          mountPath: /var/run/secrets/tilldev
          readOnly: true
  volumes:
    - name: tilldev
      projected:
        sources:
          - serviceAccountToken:
              path: token
              audience: tilldev:secrets:3f1c…
              expirationSeconds: 3600

Google Cloud, Fly.io and AWS

Nothing to configure. The token comes from the Google metadata server or the Fly Machines API. On AWS, the CLI and SDK use the credentials the workload already has: environment variables, an EKS service account role (IRSA or Pod Identity), ECS task credentials, or the EC2 instance profile. They sign a GetCallerIdentity request, which TillSecrets passes to AWS to learn who signed it. The secret access key stays on the workload, and each signed request works once, for five minutes.

A server with no platform identity

Platforms without a signed instance identity, such as DigitalOcean Droplets, RunPod, most VPS hosts and bare metal, use the server's host key. TillSecrets sends a challenge encrypted to that key, and only the server holding it can answer. Unlike a host-bound token, there is no bearer secret on disk, and tokens stop working when they expire.

terminal
# On the server, as the user the service runs as
tilldev secrets host-key init          # ~/.tilldev/host-key, public half in host-key.pub

# Where you manage TillSecrets, with host-key.pub copied over
tilldev secrets identities create --project web --env prod --machine --host-key @host-key.pub

# On the server: nothing else is stored there
TILLSECRETS_IDENTITY=3f1c… tilldev secrets exec --env prod -- node server.js

Any other OIDC issuer

Put the token in TILLSECRETS_ID_TOKEN, or in a file named by TILLSECRETS_ID_TOKEN_FILE. Its audience must be the identity's, shown when you create it: tilldev:secrets:<id>. On Jenkins, add an OpenID Connect id token credential with that audience and bind it to TILLSECRETS_ID_TOKEN.

Node

server.ts
import { load, createClient } from '@tillstack/secrets-node'

// Reads TILLSECRETS_IDENTITY and finds the platform's proof on its own.
await load()

// Or say which identity to use.
const client = createClient({ identity: '3f1c…' })
const { secrets } = await client.pull()

The client reuses a token until a minute before it expires, then gets a new one. The answer always comes back encrypted with X-Wing; see encrypted delivery.

03Checks

What is checked

  • OIDC tokens must be signed by the issuer's current keys (RSA, ECDSA or EdDSA; never none or a shared secret). They must carry this identity's audience and an expiry, and be valid for at most 24 hours (48 on Modal, whose tokens live that long). Every condition must match. Keys come from the issuer's discovery document over https, and the issuer in it must match exactly. Each platform takes only its own issuer: a Buildkite identity accepts only Buildkite's, and a CircleCI identity only your organization's.
  • Re-created names are refused. The first time a GitHub or GitLab identity is used, it records the repository's numeric id, and the owner's when you named one. Buildkite, HCP Terraform, Vercel and Deno Deploy identities do the same for the organization, pipeline, workspace, project and app they name. One deleted and re-created under the same name, by anyone, gets new ids and is refused. Changing what an identity trusts clears these ids. Google tokens are accepted by email only when Google marks the email as verified.
  • AWS requests must be signed over the identity's audience. They must be for STS GetCallerIdentity and nothing else, and less than five minutes old. A role ARN matches any session of that role.
  • Machine challenges expire after two minutes and work once.
04Tokens

The token it gets

Each exchange issues a ts_ token with the identity's project, environment, scope and keys. It lasts an hour by default, and you can set 5 minutes to 12 hours. Tokens from identities don't appear among the project's service tokens. Revoking the identity revokes every token it issued, at once. If the identity has a host key, its tokens answer only encrypted to that key.

manage
tilldev secrets identities --project web                       # list, with uses and refusals
tilldev secrets identities show <id> --project web              # last refusal and setup steps
tilldev secrets identities update <id> --project web --branch release --ttl 30m
tilldev secrets identities update <id> --project web --claim environment=     # drop a condition
tilldev secrets identities rm <id> --project web                # revoke it and its tokens
05Refusals

When it says no

A refused exchange gets no token and answers with a reason. The identity counts refusals and shows the last one, and each is recorded in the audit log with the address it came from.

ReasonMeans
claim_mismatch:<claim>The token is genuine, but that claim doesn’t match. The error names what the identity expects.
bad_tokenThe token didn’t verify: wrong issuer or audience, expired, valid for longer than its platform allows, or signed with a key the issuer doesn’t publish.
issuer_unreachableThe issuer’s keys could not be fetched. Try again, or pin its keys with --jwks.
arn_mismatchAWS says the request came from a different principal or account.
aws_refused, replayedAWS rejected the signature, or that signed request was already used.
stale_proofThe machine challenge expired or was already answered.
host_key_required, host_key_mismatchThe identity answers only to its host key, and this caller doesn’t have it.
revoked, unknown_identityThe identity was revoked, or doesn’t exist.
06Limits

What to keep in mind

  • An identity is only as narrow as its conditions. acme/* with no ref lets every workflow in every acme repository read the environment. Anyone who can push a branch can usually run a workflow, so pin ref to a protected branch or tag, or a GitHub environment with reviewers.
  • You are trusting the issuer. Whoever controls its signing keys can issue a token that matches. Machine identities rely on nobody but the host key.
  • Spacelift, Semaphore and Modal can't put a TillSecrets audience in their tokens; they always use their own. If a run sends its token to another service, that service could use it here until it expires (an hour on Spacelift, two days on Modal). Pin the stack, project or app, and send the token only to services you trust. Every other platform gets a token made for this identity alone.
  • Azure Pipelines isn't supported. Its tokens can only be exchanged with Microsoft Entra, so a service that accepted them could be handed one meant for Azure. Use a managed identity on a self-hosted agent, or a host key.
  • OIDC and AWS signatures are classical (RSA, ECDSA, EdDSA or AWS SigV4), chosen by the platform. The token TillSecrets sends back is encrypted with X-Wing (ML-KEM-768 with X25519). The machine challenge uses X-Wing too.
  • Cloudflare Workers have no platform identity to offer. Use a host-bound service token there.
  • A token keeps working until it expires or its identity is revoked; it isn't re-checked on each pull.
07API

API

endpoints
GET    /api/secrets/projects/{id}/identities
POST   /api/secrets/projects/{id}/identities                  needs approval: identity.create
GET    /api/secrets/projects/{id}/identities/{identityId}
PATCH  /api/secrets/projects/{id}/identities/{identityId}     needs approval (except a new name): identity.update
DELETE /api/secrets/projects/{id}/identities/{identityId}

# Called by the SDKs and the CLI on the workload; no login
GET    /api/secrets/workload/identities/{identityId}
POST   /api/secrets/workload/challenge   {"identity": "…"}
POST   /api/secrets/workload/exchange    {"identity": "…", "id_token" | "aws" | "answer": …}

The workload endpoints are rate-limited per identity and per address. See the API reference for the request and response shapes.

Audit
identity.create, identity.update and identity.revoke for changes; identity.exchange for each token issued, with the token's subject; identity.refused for each refusal, with its reason.