Sign in as what you are
BetaA 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.
What it trusts
| Where it runs | Proof | Condition that names it |
|---|---|---|
| GitHub Actions | The job’s OIDC token | repository (owner/repo or owner/*), plus ref, environment or any claim |
| GitLab CI | An id_tokens token | project_path, plus ref, environment or any claim |
| Bitbucket Pipelines | The step’s OIDC token | The workspace (issuer) and repositoryUuid, plus branchName |
| CircleCI | A token from circleci run oidc get | The organization (issuer) and project ID, plus the branch |
| Buildkite | A token from buildkite-agent oidc request-token | organization_slug, plus pipeline_slug and build_branch |
| Semaphore | The job’s OIDC token | The organization (issuer) and prj_id, plus branch |
| HCP Terraform and Terraform Enterprise | A workload identity token for runs | terraform_organization_name, plus the workspace, project or run phase |
| Spacelift | The run’s OIDC token | The account (issuer) and the stack (callerId) |
| Google Cloud (Compute Engine, Cloud Run, Functions, GKE) | The metadata server’s identity token | The service account’s numeric id (sub) or email |
| Azure (App Service, Functions, Container Apps, VMs) | A managed identity token | The tenant (issuer), your app registration, and the identity’s object ID (oid) |
| AWS (EC2, ECS, EKS, Lambda) | A signed sts:GetCallerIdentity request | A role or IAM user ARN with a literal account |
| Kubernetes (EKS, GKE, AKS, your own) | A projected service account token | sub, for example system:serviceaccount:web:api |
| Fly.io Machines | The Machines API’s OIDC token | The organization (issuer) and app_name |
| Vercel functions and builds | The deployment’s OIDC token | The team (issuer) and project, plus environment |
| Deno Deploy | A token from @deno/oidc | org_slug, plus app_slug and context_name |
| Modal | The container’s identity token | workspace_id, plus app_name and environment_name |
| A server with no platform identity | A challenge encrypted to its host key | The host key itself |
| Any other OIDC issuer (Jenkins, Forgejo, your own IdP) | A token from that issuer | At 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.
# 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=deployCreating an identity, or changing what it trusts, waits for your approval. The same is available under Secrets → Workload identities in the dashboard.
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.
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.shGitLab CI
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.shBitbucket 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.
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.shCircleCI, 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.
# .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.shHCP 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 (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.
# 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 identityVercel
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.
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
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.
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: 3600Google 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.
# 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.jsAny 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
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.
What is checked
- OIDC tokens must be signed by the issuer's current keys (RSA, ECDSA or EdDSA; never
noneor 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
GetCallerIdentityand 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.
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.
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 tokensWhen 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.
| Reason | Means |
|---|---|
| claim_mismatch:<claim> | The token is genuine, but that claim doesn’t match. The error names what the identity expects. |
| bad_token | The 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_unreachable | The issuer’s keys could not be fetched. Try again, or pin its keys with --jwks. |
| arn_mismatch | AWS says the request came from a different principal or account. |
| aws_refused, replayed | AWS rejected the signature, or that signed request was already used. |
| stale_proof | The machine challenge expired or was already answered. |
| host_key_required, host_key_mismatch | The identity answers only to its host key, and this caller doesn’t have it. |
| revoked, unknown_identity | The identity was revoked, or doesn’t exist. |
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 pinrefto 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.
API
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.
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.