Workload identity federation#
A CI run, a Kubernetes workload, or a Google Cloud service account already holds an OIDC token its platform signed. With workload identity federation it exchanges that token for a short-lived Lens Agents API token, so there is no long-lived secret to store, rotate, or leak.
Three pieces:
| Piece | Who manages it | What it is |
|---|---|---|
| Trusted issuer | Org admin | An OIDC issuer the organization accepts tokens from, the audience those tokens must carry, and the claims every rule must pin. |
| Federated API token | Project admin | An API token with no secret and an ADMIN or MEMBER role on one project. |
| Rule | Project admin | The issuer and the exact value of each claim a workload token must have to act as that federated token. |
Org members can list issuers, and project members can list federated tokens and rules. Changes need a signed-in person: an API token cannot add an issuer or a rule. Issuers are on the org's Trusted Issuers page, federated tokens and rules on the project's Federated Tokens page. There are no MCP tools for this.
Trust an issuer#
A preset fills in the issuer URL and the claims every rule must pin. Pin ids that cannot be registered again, which is why the presets use ids and not names.
| Preset | Issuer | Required claims |
|---|---|---|
github |
https://token.actions.githubusercontent.com |
repository_id, repository_owner_id |
gitlab |
https://gitlab.com |
project_id, namespace_id |
buildkite |
https://agent.buildkite.com |
organization_id, pipeline_id |
google-cloud |
https://accounts.google.com |
sub |
kubernetes |
Your cluster's, set with --issuer |
sub |
nexusctl workload-identity issuer add --org acme --preset github
nexusctl workload-identity issuer list --org acme
issuer list also prints the audience to use for the organization. --audience overrides it, and --required-claim, repeated, overrides the preset's claims. Without a preset, give --name, --issuer, and at least one --required-claim.
curl -fsS -X POST \
-H "Authorization: Bearer $LENS_AGENTS_TOKEN" \
-H "Content-Type: application/json" \
https://agents.example.com/v1/orgs/$ORG_ID/trusted-issuers \
-d '{
"name": "GitHub Actions",
"issuer": "https://token.actions.githubusercontent.com",
"audience": "https://agents.example.com/orgs/'"$ORG_ID"'",
"requiredClaims": ["repository_id", "repository_owner_id"]
}'
GET /v1/orgs/$ORG_ID/trusted-issuers returns the issuers and defaultAudience, the audience unique to this organization, so a token minted for it is useless in any other.
The platform reads the issuer's keys from its OIDC discovery document when you add it, and refuses an issuer it cannot read. For an issuer it cannot reach, such as a private cluster, pin the keys with --jwks-file, or name another location with --jwks-uri. Keys are fetched over https only, without following redirects and never from a private or link-local address. A key set holding private or symmetric keys is refused.
Create a federated token and its rules#
nexusctl federated-token create --project production --name ci-deploy --role MEMBER
nexusctl federated-token rule add ci-deploy --project production \
--issuer "GitHub Actions" \
--claim repository_id=123456789 \
--claim repository_owner_id=987654 \
--claim ref=refs/heads/main
curl -fsS -X POST \
-H "Authorization: Bearer $LENS_AGENTS_TOKEN" \
-H "Content-Type: application/json" \
https://agents.example.com/v1/projects/$PROJECT_ID/federated-tokens \
-d '{"name": "ci-deploy", "role": "MEMBER"}'
curl -fsS -X POST \
-H "Authorization: Bearer $LENS_AGENTS_TOKEN" \
-H "Content-Type: application/json" \
https://agents.example.com/v1/projects/$PROJECT_ID/federated-tokens/$FEDERATED_TOKEN_ID/rules \
-d '{
"issuerId": "<issuer-uuid>",
"claims": {
"repository_id": "123456789",
"repository_owner_id": "987654",
"ref": "refs/heads/main"
}
}'
A workload token acts as the federated token when every claim of one rule equals its value. A rule must pin every claim its issuer requires. The platform refuses a rule that does not, and refuses to add a required claim to an issuer while one of its rules does not pin it. Pin the branch or environment too, not only the repository.
Like any API token, the federated token reads org-level data as an org member does and writes only in its own project. Give it policy bindings as you would any other principal.
Exchange the token#
The workload sends its OIDC token and the federated token's ID to POST /v1/workload-identity/token, which needs no other authentication. The body is JSON in RFC 8693 token-exchange vocabulary:
curl -fsS -X POST https://agents.example.com/v1/workload-identity/token \
-H "Content-Type: application/json" \
-d '{
"subject_token": "'"$OIDC_TOKEN"'",
"subject_token_type": "urn:ietf:params:oauth:token-type:jwt",
"api_token_id": "<federated-token-uuid>"
}'
{
"access_token": "lns_...",
"issued_token_type": "urn:ietf:params:oauth:token-type:access_token",
"token_type": "Bearer",
"expires_in": 3600
}
# In GitHub Actions, nexusctl asks the runner for the OIDC token itself.
nexusctl workload-identity token \
--federated-token <federated-token-uuid> \
--audience "https://agents.example.com/orgs/<org-id>"
# Elsewhere, read it from a file, such as a Kubernetes projected service account token.
nexusctl workload-identity token \
--federated-token <federated-token-uuid> \
--token-file /var/run/secrets/tokens/lens-agents
The token is printed to stdout. NEXUS_FEDERATED_TOKEN can stand in for --federated-token.
Send access_token as a bearer token anywhere an API token works, private sandbox ports included. Every refused token gets the same 401, so it tells a caller nothing about why.
A GitHub Actions job:
permissions:
id-token: write # lets the run request its OIDC token
contents: read
jobs:
deploy:
runs-on: ubuntu-latest
env:
LENS_AGENTS_URL: https://agents.example.com
FEDERATED_TOKEN_ID: <federated-token-uuid> # an ID, not a secret
AUDIENCE: https://agents.example.com/orgs/<org-id>
steps:
- name: Get a Lens Agents token
run: |
OIDC=$(curl -fsS -H "Authorization: Bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \
"$ACTIONS_ID_TOKEN_REQUEST_URL&audience=$AUDIENCE" | jq -r .value)
TOKEN=$(curl -fsS "$LENS_AGENTS_URL/v1/workload-identity/token" \
-H 'Content-Type: application/json' \
-d "{\"subject_token\":\"$OIDC\",\"subject_token_type\":\"urn:ietf:params:oauth:token-type:jwt\",\"api_token_id\":\"$FEDERATED_TOKEN_ID\"}" \
| jq -r .access_token)
echo "::add-mask::$TOKEN"
echo "LENS_AGENTS_TOKEN=$TOKEN" >> "$GITHUB_ENV"
- run: curl -fsS -H "Authorization: Bearer $LENS_AGENTS_TOKEN" "$LENS_AGENTS_URL/v1/orgs"
Find out why a token is refused#
Explain a token on the project's Federated Tokens page, or the command below, verifies a workload token and shows which rule each claim matched or missed. It grants nothing and stores nothing.
nexusctl workload-identity explain --project production --token-file token.jwt
The API is POST /v1/projects/$PROJECT_ID/workload-identity/explain with {"token": "..."}.
Token verification allows 30 seconds of clock skew. A token with no subject or no expiry is refused.
Lifetime and revocation#
| Value | Default | Purpose |
|---|---|---|
workloadIdentity.enabled |
true |
false removes the exchange and the management API, and stops every session at once. |
workloadIdentity.sessionTtlSeconds |
3600 |
Life of an exchanged token, in seconds, also past the expiry of the workload's own token. |
An exchanged token stops working at once when its rule, its issuer, or its federated token is removed or revoked.
What is audited#
| Action | When |
|---|---|
workload-identity.issuer.create, .update, .delete |
An org admin changes a trusted issuer. |
api-token.federated.create, .revoke |
A project admin creates or revokes a federated token. |
api-token.federated.rule.create, .rule.delete |
A project admin adds or removes a rule. |
api-token.workload-exchange |
A workload exchanges a token, or presents a verified token that no rule admits. The refusal is recorded at most once every five minutes for each federated token; other refusals are only logged. |
See Audit trail.
Related#
- API tokens — tokens with a stored secret
- SSO — interactive sign-in for people
- Policy bindings — what a token may reach
Federating an issuer that is not one of the presets? Talk to us.