CI token exchange

Let GitHub Actions and GitLab CI jobs exchange their OIDC ID token for a 15-minute Noru token, with no stored API key.

What it is

A CI job on GitHub Actions or GitLab.com can call the Noru API without a stored secret. The job asks its CI provider for a signed OIDC ID token, and Noru exchanges it for an access token that lasts 15 minutes and works everywhere an API key does, on the REST API and on the MCP endpoint.

What decides which jobs get a token is a CI trust record: the repository, the branches or tags, optionally the deployment environment, and the most scopes a token may carry.

Where to find it

SettingsDeveloperCI trust

Who can create trust records

Only admins, like API keys: the whole Settings page requires the settings.manage capability. A token issued under a record acts as the admin who created it, inside that one organization. Their membership and role are checked again on every exchange and every request: if they leave the organization the record stops issuing tokens and issued tokens stop working, and if their role narrows, so do the tokens' scopes.

Create a trust record

Click New trust record. The New CI trust record form opens.
Enter a Name you will recognise, for example "Noru push from main".
Pick the Provider: GitHub Actions or GitLab.com.
Enter the Repository (owner/name) or, on GitLab, the full Project path (group/subgroup/project).
Enter a Ref pattern: a full branch or tag ref such as refs/heads/main. * matches any characters, so refs/tags/v* covers every tag that starts with v. Pull request refs cannot be trusted.
Optionally enter an Environment. Jobs are then accepted only when they run in that deployment environment, so its protection rules, such as required reviewers, apply.
Tick the Scopes a token may carry. A job can ask for fewer, never more.
Click Create trust record. A snippet for your provider appears with the record's id filled in. Copy it into your pipeline.

Repository and owner ids

Repository names can change hands. A record therefore binds to the numeric ids the provider puts in the ID token: on GitHub the repository id and its owner's id, on GitLab the project id and its namespace id. Enter them under Advanced when you create the record, or leave them blank and the first accepted job's verified token records them. From then on a renamed repository keeps working, while a deleted and re-created repository, or one moved to another owner, does not.

Use it in a pipeline

The record's id is the audience of the exchange. The ID token itself is requested for the audience https://api.noru.tech.

GitHub Actions

permissions:
  id-token: write
  contents: read
steps:
  - name: Get a Noru token
    run: |
      ID_TOKEN=$(curl -sS -H "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" "$ACTIONS_ID_TOKEN_REQUEST_URL&audience=https://api.noru.tech" | jq -er .value)
      NORU_API_KEY=$(curl -sS https://api.noru.tech/v1/oidc/token \
        -d grant_type=urn:ietf:params:oauth:grant-type:token-exchange \
        -d subject_token_type=urn:ietf:params:oauth:token-type:id_token \
        -d audience=<trust record id> \
        --data-urlencode "subject_token=$ID_TOKEN" | jq -er .access_token)
      echo "::add-mask::$NORU_API_KEY"
      echo "NORU_API_KEY=$NORU_API_KEY" >> "$GITHUB_ENV"

GitLab CI

noru:
  id_tokens:
    NORU_ID_TOKEN:
      aud: https://api.noru.tech
  script:
    - |
      NORU_API_KEY=$(curl -sS https://api.noru.tech/v1/oidc/token \
        -d grant_type=urn:ietf:params:oauth:grant-type:token-exchange \
        -d subject_token_type=urn:ietf:params:oauth:token-type:id_token \
        -d audience=<trust record id> \
        --data-urlencode "subject_token=$NORU_ID_TOKEN" | jq -er .access_token)
      export NORU_API_KEY

Send the token as Authorization: Bearer $NORU_API_KEY. Add -d "scope=read:risks write:risks" to the exchange to ask for fewer scopes than the record allows. jq -e makes the step fail when the exchange is refused. GitLab cannot mask a value created while the job runs, so never echo the token there.

The ref is the one the provider writes into the ID token. A GitHub pull_request_target job carries the base branch's ref while it may run pull request code, so never give such a job id-token: write. For jobs that write to Noru, set an environment with required reviewers on the record.

The exchange

POST https://api.noru.tech/v1/oidc/token is an RFC 8693 token exchange. It takes a form-encoded body and no credential:

FieldValue
grant_typeurn:ietf:params:oauth:grant-type:token-exchange
subject_tokenThe job's ID token
subject_token_typeurn:ietf:params:oauth:token-type:id_token
audienceThe trust record's id
scopeOptional. Space-separated, within the record's scopes

A successful response:

{
  "access_token": "noru_ci_…",
  "issued_token_type": "urn:ietf:params:oauth:token-type:access_token",
  "token_type": "Bearer",
  "expires_in": 900,
  "scope": "read:risks write:risks"
}

A refusal is a 400 with error and error_description:

errorMeaning
invalid_requestA field is missing or has the wrong value
unsupported_grant_typegrant_type is not the token exchange
invalid_targetNo active trust record has this id
invalid_grantThe ID token did not verify, was already exchanged, or the record does not accept this repository, ref or environment
invalid_scopeA requested scope is outside the record, or its creator's role no longer allows it

Limits

LimitValue
Token lifetime15 minutes. There is no refresh token; exchange again.
ID tokensEach can be exchanged once. Request a new one for each exchange.
Exchange rate60 requests per minute per client IP
API rate500 requests per 10 minutes, shared by every token from one trust record
ProvidersGitHub Actions (https://token.actions.githubusercontent.com) and GitLab.com (https://gitlab.com). GitHub Enterprise Cloud custom issuers and self-managed GitLab are not accepted.

Revoke

Click the trash icon on the record and confirm Revoke trust record. Jobs can no longer exchange tokens under it, and tokens already issued stop working at once. The row stays in the list with a Revoked badge.

Last updated on