Documentation menu
Dashboard & analytics
Managed runners
Team & notifications
Billing & plans
Workflow files
Let your cloud trust a Latchkey job
How a Latchkey CI job signs in to AWS with a short-lived identity token instead of a stored cloud key: the issuer, the subject your trust policy matches, and the step that asks.
In short
- A step asks the runner for an identity token for one audience, and Latchkey signs it as
https://oidc.latchkey.dev, so a job can deploy with no cloud key stored anywhere. - Your trust policy matches the token by its
sub, which names your workspace, the repository and the ref, or the environment an owner or admin approved the run for or set to need no approval. - A token minted for a job of a trial run, or of a pipeline on trial, carries its
subbehindadvisory:, so a role written for live runs never trusts it. - On AWS, create an OpenID Connect provider for
https://oidc.latchkey.devwith the audiencests.amazonaws.com, then sign in withlatchkey/aws-login@1.0.0.
How it works#
A job of a Latchkey CI run can ask for an identity token: a short-lived token, signed by Latchkey, that says which workspace, repository, and ref or environment the job belongs to. Your cloud checks the signature against the public keys Latchkey publishes and compares the token's claims with the trust you wrote, and when they match it gives the job short-lived credentials of its own. No cloud key is stored in Latchkey, in a secret or in your workflow file.
A step asks for a token for one audience: the name your cloud expects in the token's aud claim, such as sts.amazonaws.com for AWS. Every other claim comes from Latchkey's own record of the job, its run and its repository, never from the request.
The issuer#
| What | Value |
|---|---|
| Issuer | https://oidc.latchkey.dev |
| Discovery document | https://oidc.latchkey.dev/.well-known/openid-configuration |
| Public keys | https://oidc.latchkey.dev/.well-known/jwks.json |
| Signature | RS256, with the key its kid names |
| Lifetime | 15 minutes, or less when the job's timeout is nearer, and never under 5 minutes |
Write the issuer exactly as above, with no trailing slash. Both documents may be cached for an hour. When Latchkey rotates its signing key, it publishes the new key beside the old one before it signs with it, so a cloud that reads the public keys from the issuer needs no change on your side.
Which jobs get a token#
A job can ask while it runs, when it is a job of a Latchkey CI run of your workflow file on your repository's own commit. These jobs get no token:
- a job of a pull request from a fork, whose steps are not given the way to ask;
- a job started with
latchkey run; - a job of a run Latchkey started to check a fix self-heal proposed;
- a job that names an environment, until an owner or admin approves its run, unless the environment needs no approval, and never in a run on a branch the environment does not accept.
A token stays valid until its exp, at most 15 minutes, even after its job ends. No token is issued after the job ends.
The subject#
The sub claim is what a trust policy matches. It takes one of four forms:
| Job | sub |
|---|---|
| A job that names no environment | org:<workspace id>:repo:<owner>/<name>:ref:<ref> |
| A job that names an environment, approved or needing no approval | org:<workspace id>:repo:<owner>/<name>:environment:<environment> |
| The first, in a trial run, for a pipeline on trial, or in advisory mode | advisory:org:<workspace id>:repo:<owner>/<name>:ref:<ref> |
| The second, in a trial run, for a pipeline on trial, or in advisory mode | advisory:org:<workspace id>:repo:<owner>/<name>:environment:<environment> |
<workspace id> is your workspace's id, the same in every token your workspace's jobs get. Copy it from Workspace ID in Settings, Account. Every Latchkey workspace's tokens come from the one issuer, so a trust policy always names your workspace id in the sub it matches. <owner>/<name> is the repository's name as Latchkey last read it from your code host, so renaming the repository changes its subjects: update the roles that name it when you rename it, and remove them when you delete it, since a repository of your workspace that takes the name later gets the same subjects. <ref> the ref of the run the job belongs to, and <environment> the environment the job was bound to, as the workflow file spells it, case kept: the leg's own for a matrix value, and the chosen one for a map of branches. No part can hold a :, so no subject can be read as another.
To see the exact subject a job gets, run this step once in it. It prints the decoded sub and the workspace id, never the token, which the log hides in any case:
- name: Show this job's identity run: | answer=$(curl -sS --noproxy '*' -H "Authorization: Bearer $LATCHKEY_ID_TOKEN_REQUEST_TOKEN" \ "$LATCHKEY_ID_TOKEN_REQUEST_URL&audience=sts.amazonaws.com") if ! token=$(jq -er .value <<<"$answer" 2>/dev/null); then echo "No identity token: ${answer:-no answer}" >&2 exit 1 fi jq -rR 'split(".")[1] | gsub("-"; "+") | gsub("_"; "/") | @base64d | fromjson | .sub, .organization_id' <<<"$token"
A repository in advisory mode runs its jobs as a trial beside your current CI, and a repository on Latchkey CI runs a pipeline on trial the same way while the GitHub Actions file it replaces is still at the commit. A token issued to a job of a trial run or of a pipeline on trial carries the advisory: prefix, and so does every token issued while the repository is in advisory mode, even for a run that started before the repository was switched to it. Every live subject starts with org:, so a trust policy written for live runs never matches a token with the advisory: prefix. That is by design: neither a trial run nor a pipeline on trial may deploy. A role meant for them too, such as a read-only role a test reads with, lists the advisory: form beside the live one.
A job that names an environment gets the environment form only once an owner or admin has approved its run for that environment, or without an approval when an owner or admin has set the environment to need none, and, when an owner or admin has set the environment's branches in Settings, Environments, only in a run on one of them. Its ref claim still names the run's ref. A role that trusts environment:production therefore takes only jobs an owner or admin approved for production, or that production started without an approval, in runs production accepts. An environment can need no approval only while its branches are set, and those branches are then its only guard. Until an owner or admin sets the branches, production accepts every run, on any branch, tag or pull request ref, so set them before a deploy role trusts the environment.
An approval can also come through an API key with the jobs:approve scope, on behalf of the owner or admin who created the key. An agent that holds such a key and can also push to a repository can approve the runs of its own changes, with no other person deciding.
Which ref a token names#
Every identity token names the ref of the run its job belongs to, in two claims:
ref, for examplerefs/heads/main,refs/tags/v1.4.0orrefs/pull/42/head;sub, which ends:ref:<ref>unless the job names an environment.
When a push and a pull request name the same commit, they join one run, and every job of that run carries that run's ref.
Pull requests from a branch in the same repository#
Most of the time, a pull request's jobs carry the branch's ref, refs/heads/<branch>: you push the branch, the push starts the commit's run, and the pull request joins that run. Opening a pull request for a commit that already ran starts nothing new; its checks are the push's.
A token names refs/pull/<number>/head when the pull request starts the run itself:
- your workflow file runs on
pull_requestand not onpush, so the push matches nothing and the pull request starts the run; or - the pull request's update reaches Latchkey before Latchkey has planned the commit's jobs from its push.
So a job in a pull request can carry either form, depending on your workflow's triggers and on which event arrives first. The event_class claim names the event the run was planned for, such as branch_push for a push or pr_update for a pull request. On a pull request's run, the base_ref claim names the branch the pull request targets.
Scheduled runs#
A scheduled run is a run of its own, at the head of the default branch, and its jobs carry the default branch's ref, such as refs/heads/main.
Pull requests from a fork#
A job in a pull request from a fork gets no identity token. Only code pushed to your repository can ask for one.
What a trust policy should match#
- Deploys: match the exact subject that deploys. A deploy role for a job that names an environment matches
org:<workspace id>:repo:<owner>/; one for a branch matches<name>:environment:production org:<workspace id>:repo:<owner>/, or a release tag. A token names<name>:ref:refs/ heads/ main refs/heads/mainonly on a run of a commit that is onmain, so a role bound tomainruns only code that is onmain. A deploy role never lists theadvisory:form. - Never match a pull request ref for anything that matters.
refs/pull/<number>/headchanges with every pull request number and does not appear on every pull request job. - A role any job of the repository may take (tests that read a staging bucket, preview builds): match every ref of the repository, by the prefix
org:<workspace id>:repo:<owner>/. That prefix matches neither the environment form nor the<name>:ref: advisory:form, so a role that should also take jobs that name an environment, or trial runs and pipelines on trial, lists those prefixes too. - Always match
sub, with your workspace id in it. Every Latchkey workspace's tokens come from the same issuer, so a trust that checks only the audience, or only the repository's name, also admits another workspace's jobs.
The claims#
| Claim | What it holds |
|---|---|
iss | https://oidc.latchkey.dev |
aud | The one audience the step asked for |
sub | The subject, in one of the four forms above |
iat, nbf, exp | When the token was issued, when it becomes valid and when it expires, in seconds |
jti | An id of its own for every token |
organization_id | Your workspace id |
repository | The repository as owner/name, as Latchkey last read it from your code host |
repository_id | Latchkey's id for the repository |
job_id | The job |
run_id, attempt | The run, and its attempt counting from 1 |
ref | The ref of the run |
sha | The commit the run is pinned to |
pipeline_id, step_key | The pipeline and the step, which keep their ids on every ref and commit |
base_ref | The base branch of a pull request or of a merge group, as refs/heads/<branch>; on a push, the push's own base_ref when the code host sends one |
actor | Who started the run |
event_class | The event the run was planned for, such as branch_push or pr_update |
A claim with no value is left out rather than sent empty, so a rule that matches an empty value never matches by accident.
Ask for a token in a step#
In a job that can get a token, every step has two variables: LATCHKEY_ID_TOKEN_REQUEST_URL, an address on the runner itself, and LATCHKEY_ID_TOKEN_REQUEST_TOKEN, the credential to ask it with. A job of a pull request from a fork has neither; any other job that cannot get a token is refused with one of the words below. Append &audience= and the audience to the address, send the credential as a bearer token, and the runner answers {"value": "<token>", "job_id": "<job id>"}:
- name: Get an identity token for AWS run: | answer=$(curl -sS --noproxy '*' -H "Authorization: Bearer $LATCHKEY_ID_TOKEN_REQUEST_TOKEN" \ "$LATCHKEY_ID_TOKEN_REQUEST_URL&audience=sts.amazonaws.com") if ! token=$(jq -er .value <<<"$answer" 2>/dev/null); then echo "No identity token: ${answer:-no answer}" >&2 exit 1 fi
With CLI 0.9.0 or later installed in the job (npm install -g @latchkeydev/cli), token=$(latchkey oidc request-token --audience sts.amazonaws.com) makes the same request. It encodes the audience itself, keeps the request on the runner, and exits 1 with the reason when there is no token; --out <file> writes the token to a file readable by its owner alone instead of printing it.
The audience is one string of 1 to 255 visible ASCII characters, with no space; percent-encode an audience that holds &, +, # or %. The runner hides the token in the job's log, and in what self-heal reads, before the step holds it. Ask for the same audience again and the runner answers the token it holds while at least a third of its life is left, and a new one after that. --noproxy '*' keeps the request on the runner when your job sets a proxy.
A request without the credential gets a bare 401, and one that sends anything but the audience a bare 400. When Latchkey does not issue a token, the runner answers an error status with {"refusal": "<word>"}, after retrying for up to 25 seconds where a retry can help:
| Word | What it means |
|---|---|
job_class_ineligible | The job does not run your repository's own commit in a run of your workflow file, such as a job started with latchkey run or a run of a fix self-heal proposed |
job_not_running | The job is not running |
configuration_incomplete | Latchkey is not set up to issue tokens here yet |
claims_unavailable, signing_failed, ledger_unavailable, unexpected, mint_unavailable | Latchkey could not issue the token just now; run the step again |
mint_refused | Latchkey refused the request: the audience is not one it accepts, or the job's own credential was refused, as when the job has ended |
masker_refused | The token could not be hidden in the log, so it was not given to the step |
AWS#
Create an OpenID Connect provider for the issuer once in each AWS account, with the audience sts.amazonaws.com. Leave out a thumbprint: AWS checks the issuer's certificate against the certificate authorities it trusts.
Then trust the provider from the role. Of the claims a Latchkey token carries, AWS can match only sub and aud, so whatever a role depends on has to be in the subject it matches. This deploy role takes only jobs of acme/api that name the environment production, in runs an owner or admin approved, or in runs on its branches where production needs no approval:
{ "Version": "2012-10-17", "Statement": [ { "Effect": "Allow", "Principal": { "Federated": "arn:aws:iam::123456789012:oidc-provider/oidc.latchkey.dev" }, "Action": "sts:AssumeRoleWithWebIdentity", "Condition": { "StringEquals": { "oidc.latchkey.dev:aud": "sts.amazonaws.com", "oidc.latchkey.dev:sub": "org:<workspace id>:repo:acme/api:environment:production" } } } ] }
A read-only role a test reads with may take any job of the repository that names no environment, trial runs and pipelines on trial included. StringLike reads * as any run of characters, and its list matches when any one of its values does:
{ "Version": "2012-10-17", "Statement": [ { "Effect": "Allow", "Principal": { "Federated": "arn:aws:iam::123456789012:oidc-provider/oidc.latchkey.dev" }, "Action": "sts:AssumeRoleWithWebIdentity", "Condition": { "StringEquals": { "oidc.latchkey.dev:aud": "sts.amazonaws.com" }, "StringLike": { "oidc.latchkey.dev:sub": [ "org:<workspace id>:repo:acme/api:ref:*", "advisory:org:<workspace id>:repo:acme/api:ref:*" ] } } } ] }
In the job, sign in with latchkey/
on: push: branches: [main] jobs: deploy: runs-on: small environment: production steps: - uses: latchkey/aws-login@1.0.0 with: role-to-assume: arn:aws:iam::123456789012:role/deploy aws-region: us-east-1 - run: aws sts get-caller-identity