Skip to main content
Private preview. fremforge is in private preview — invited customers only. Content is still subject to change. Request access →
OIDC federation

OIDC token federation

Each fremforge CI job can receive a short-lived OIDC token: an RS256-signed JWT issued by the fremforge runner controller. Cloud providers (T Cloud, AWS, Azure, GCP) can be configured to trust this token and exchange it for scoped, time-limited credentials. No deploy keys stored as secrets, nothing to rotate on a schedule.

Claims reference

The fremforge runner JWT contains the following claims:

ClaimValue / formatExample
isshttps://frem.sh/_app/runner/oidc (single global issuer for all tenants)https://frem.sh/_app/runner/oidc
subrepo:<org>/<repo>:ref:refs/heads/<branch>repo:acme/api:ref:refs/heads/main
audConfigurable; default https://frem.shhttps://frem.sh
repository_ownerOrg slug — pin trust-policy conditions on this claim for per-tenant isolation under the shared issueracme
repository<org>/<repo>acme/api
refFull git refrefs/heads/main
shaCommit SHAa1b2c3d4...
environmentDeployment environment name (if job declares one)production
job_workflow_refWorkflow file path and refacme/api/.forgejo/workflows/deploy.yaml@refs/heads/main

The sub claim is the primary trust anchor in most provider trust policies. It uniquely identifies the org, repo, and branch combination. For per-tenant isolation in shared cloud accounts, add a condition pinning on repository_owner (your org slug) — same pattern as GitHub Actions trust on token.actions.githubusercontent.com. The fremforge runner controller emits a single global issuer for all tenants; trust isolation is enforced at the claim layer, not the issuer-URL layer.

Getting the token in a workflow

Add id-token: write to the job’s permissions block. The runner then populates ACTIONS_ID_TOKEN_REQUEST_TOKEN and ACTIONS_ID_TOKEN_REQUEST_URL as environment variables, which you use to fetch the actual JWT:

permissions:
  id-token: write
  contents: read

jobs:
  deploy:
    runs-on: fremforge
    steps:
      - name: Get OIDC token
        run: |
          TOKEN=$(curl -sS \
            -H "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \
            "$ACTIONS_ID_TOKEN_REQUEST_URL&audience=https://frem.sh" \
            | jq -r .value)
          echo "TOKEN=$TOKEN" >> $GITHUB_ENV

Most provider-specific steps (AWS, Azure, GCP) handle the token fetch internally. You only write the manual fetch above if you need the raw JWT for a custom trust flow.

T Cloud agency trust

T Cloud’s IAM federation model is OpenStack-derived: you register the OIDC issuer as an identity provider, attach a mapping that translates ID-token claims to local user attributes, then exchange the runner’s ID token for a T Cloud token at /v3/auth/tokens and assume an agency scoped to your project. There is no AWS-style trust-policy JSON; scoping happens via the mapping and the agency’s permissions.

Step 1: Create an OpenID Connect identity provider

In the T Cloud console, go to IAM → Identity Providers → Create Identity Provider.

  • Type: Virtual user
  • Protocol: OpenID Connect
  • Access type: Programmatic access (program)

After creation, configure the OIDC settings:

  • Identity provider URL (idp_url): https://frem.sh/_app/runner/oidc (single global issuer; matches the iss claim on every fremforge runner ID token).
  • Client ID (client_id): https://frem.sh, must equal the aud claim on the runner ID token.
  • Signing key (signing_key): paste the JWKS public-key JSON. Fetch it once from https://frem.sh/_app/runner/oidc/.well-known/openid-configurationjwks_uri and copy the JSON. T Cloud does not auto-fetch JWKS, so re-paste when fremforge announces a JWKS rotation (announced on the trust page; rotations are annual at most).

Step 2: Add a claim mapping

In the same identity provider, add a mapping that translates ID-token claims to local IAM attributes and restricts which subjects can federate:

[
  {
    "local": [
      {
        "user": {
          "name": "fremforge-{0}-{1}"
        }
      }
    ],
    "remote": [
      { "type": "repository_owner" },
      { "type": "repository" },
      {
        "type": "sub",
        "any_one_of": [
          "repo:<your-org>/<your-repo>:ref:refs/heads/main"
        ]
      }
    ]
  }
]

The any_one_of constraint on sub is the scoping equivalent of an AWS trust-policy StringEquals condition: T Cloud accepts the federation only when the ID token’s sub claim matches one of the listed values. Add more sub patterns to allow additional branches, tags, or repos.

Step 3: Create an agency to assume

Create an agency (IAM → Agencies → Create Agency) with the following:

  • Agency type: Account (cloud service of another T Cloud account does not apply here).
  • Delegated party: select the OpenID Connect identity provider you created in Step 1.
  • Permissions: attach the policies the CI job needs (e.g. OBS Administrator scoped to the workload OBS bucket, or CCE FullAccess scoped to the workload project).

Note the agency name and the project where you scope it.

Step 4: Use the credentials in a workflow

The runner fetches an ID token, exchanges it for a T Cloud token at /v3/auth/tokens using the id_token auth method, and then optionally assumes the agency:

permissions:
  id-token: write
  contents: read

jobs:
  deploy:
    runs-on: fremforge
    steps:
      - name: Get fremforge OIDC token
        run: |
          TOKEN=$(curl -sS \
            -H "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \
            "$ACTIONS_ID_TOKEN_REQUEST_URL&audience=https://frem.sh" \
            | jq -r .value)
          echo "FREMFORGE_OIDC_TOKEN=$TOKEN" >> $GITHUB_ENV

      - name: Exchange for T Cloud token
        run: |
          RESP=$(curl -sS -i -X POST \
            "https://iam.eu-de.otc.t-systems.com/v3/auth/tokens" \
            -H "Content-Type: application/json" \
            -d "{
              \"auth\": {
                \"identity\": {
                  \"methods\": [\"id_token\"],
                  \"id_token\": {\"id\": \"$FREMFORGE_OIDC_TOKEN\"}
                },
                \"scope\": {
                  \"project\": {\"name\": \"eu-de_<your-project>\"}
                }
              }
            }")

          # T Cloud returns the token in the X-Subject-Token response header
          TCLOUD_TOKEN=$(echo "$RESP" | grep -i '^x-subject-token:' | awk '{print $2}' | tr -d '\r')
          echo "::add-mask::$TCLOUD_TOKEN"
          echo "OS_AUTH_TOKEN=$TCLOUD_TOKEN" >> $GITHUB_ENV

Alternatively, use the T Cloud CLI (otc) or OpenTofu with the opentelekomcloud provider. Both accept OS_AUTH_TOKEN from the environment without further configuration.

Notes on T Cloud specifics

  • T Cloud does not auto-rotate JWKS. You paste the signing key once at provider creation; if fremforge rotates the OIDC signing key (announced on the trust page), re-paste the new JWKS JSON. AWS, Azure, and GCP all auto-fetch from the discovery document; T Cloud requires the explicit paste.
  • The exchanged token is in the X-Subject-Token response header, not in the response body, this is OpenStack-Keystone behaviour. The body contains the token’s metadata (catalog, project, expiry).
  • Agency scoping is enforced both by the mapping (sub claim must match) and by the permissions attached to the agency. Use both layers, the mapping prevents federation to the wrong workflow, the permissions prevent the federated workflow from doing more than its job needs.

AWS IAM

Step 1: Create an OIDC identity provider

In the AWS console, go to IAM → Identity providers → Add provider.

  • Provider type: OpenID Connect
  • Provider URL: https://frem.sh/_app/runner/oidc (single global issuer; per-tenant filtering is done in the trust-policy condition, not at the provider URL)
  • Audience: https://frem.sh

AWS fetches the JWKS from the discovery document to verify tokens.

Step 2: Create an IAM role with a web identity trust policy

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": {
        "Federated": "arn:aws:iam::<account-id>:oidc-provider/frem.sh/_app/runner/oidc"
      },
      "Action": "sts:AssumeRoleWithWebIdentity",
      "Condition": {
        "StringEquals": {
          "frem.sh/_app/runner/oidc:aud": "https://frem.sh",
          "frem.sh/_app/runner/oidc:repository_owner": "<your-org>",
          "frem.sh/_app/runner/oidc:sub": "repo:<your-org>/<your-repo>:ref:refs/heads/main"
        }
      }
    }
  ]
}

Attach the permissions the CI job needs to this role (e.g. AmazonS3FullAccess, AmazonECRPowerUser).

Step 3: Use in a workflow

Use the official aws-actions/configure-aws-credentials action:

permissions:
  id-token: write
  contents: read

jobs:
  deploy:
    runs-on: fremforge
    steps:
      - uses: actions/checkout@v4

      - name: Configure AWS credentials via OIDC
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::<account-id>:role/<role-name>
          aws-region: eu-central-1
          audience: https://frem.sh

      - name: Deploy
        run: aws s3 sync ./dist s3://my-bucket/

The action handles the token fetch, sts:AssumeRoleWithWebIdentity call, and credential injection automatically.

Azure (Entra Workload Identity)

Step 1: Create federated credentials on an app registration

  1. In the Azure portal, go to Entra ID → App registrations → <your app> → Certificates & secrets → Federated credentials.
  2. Click Add credential.
  3. Scenario: Other issuer.
  4. Fill in:
    • Issuer: https://frem.sh/_app/runner/oidc (single global issuer; tenant isolation is enforced by the Subject identifier below)
    • Subject identifier: repo:<your-org>/<your-repo>:ref:refs/heads/main
    • Audience: https://frem.sh
  5. Save.

Step 2: Use in a workflow

permissions:
  id-token: write
  contents: read

jobs:
  deploy:
    runs-on: fremforge
    steps:
      - uses: actions/checkout@v4

      - name: Get OIDC token
        run: |
          TOKEN=$(curl -sS \
            -H "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \
            "$ACTIONS_ID_TOKEN_REQUEST_URL&audience=https://frem.sh" \
            | jq -r .value)
          echo "OIDC_TOKEN=$TOKEN" >> $GITHUB_ENV

      - name: Azure login via federated token
        run: |
          az login \
            --service-principal \
            --tenant <tenant-id> \
            --client-id <client-id> \
            --federated-token "$OIDC_TOKEN"

      - name: Deploy
        run: az webapp deploy --resource-group my-rg --name my-app --src-path ./dist

The app registration’s client ID and your Entra tenant ID are not secrets. They can be stored as plain workflow variables. Only the OIDC token is sensitive, and it expires after the job completes.

GCP Workload Identity

Step 1: Create a Workload Identity pool and OIDC provider

# Create the pool
gcloud iam workload-identity-pools create fremforge-pool \
  --location global \
  --display-name "fremforge CI"

# Create the OIDC provider inside the pool
gcloud iam workload-identity-pools providers create-oidc fremforge-provider \
  --location global \
  --workload-identity-pool fremforge-pool \
  --issuer-uri "https://frem.sh/_app/runner/oidc" \
  --allowed-audiences "https://frem.sh" \
  --attribute-mapping "google.subject=assertion.sub,attribute.repository=assertion.repository,attribute.repository_owner=assertion.repository_owner"

Step 2: Grant service account impersonation

# Allow the pool to impersonate a service account, scoped by sub claim
gcloud iam service-accounts add-iam-policy-binding <sa>@<project>.iam.gserviceaccount.com \
  --role roles/iam.workloadIdentityUser \
  --member "principalSet://iam.googleapis.com/projects/<project-number>/locations/global/workloadIdentityPools/fremforge-pool/attribute.sub/repo:<your-org>/<your-repo>:ref:refs/heads/main"

Step 3: Use in a workflow

permissions:
  id-token: write
  contents: read

jobs:
  deploy:
    runs-on: fremforge
    steps:
      - uses: actions/checkout@v4

      - name: Authenticate to GCP via OIDC
        uses: google-github-actions/auth@v2
        with:
          workload_identity_provider: projects/<project-number>/locations/global/workloadIdentityPools/fremforge-pool/providers/fremforge-provider
          service_account: <sa>@<project>.iam.gserviceaccount.com
          audience: https://frem.sh

      - name: Deploy
        run: gcloud run deploy my-service --image gcr.io/my-project/my-image

The google-github-actions/auth action works on fremforge runners. It uses the same ACTIONS_ID_TOKEN_REQUEST_TOKEN / ACTIONS_ID_TOKEN_REQUEST_URL mechanism.

Troubleshooting

Audience mismatch.

The aud claim in the token must match what the provider expects. If the trust policy or provider is configured for a different audience (e.g. sts.amazonaws.com instead of https://frem.sh), the exchange fails with an audience validation error. Set audience: https://frem.sh explicitly in the provider config and in the token request (use &audience=https://frem.sh in the curl URL).

sub claim does not match the trust policy condition.

The sub claim format is exact: repo:<org>/<repo>:ref:refs/heads/<branch>. Common mismatches:

  • Branch name typo (main vs master).
  • The trust policy uses a tag ref (refs/tags/v1.0) but the job runs on a branch.
  • The policy uses refs/heads/main but the job runs on a PR branch (refs/pull/42/head). Use StringLike with a wildcard if you need to allow PRs: "repo:acme/api:ref:refs/pull/*".

Token expired, jobs longer than 1 hour.

The OIDC token has a 1-hour TTL. Jobs that run for more than 1 hour receive a fresh token each time they call the token endpoint. Each step that calls ACTIONS_ID_TOKEN_REQUEST_URL gets a new token. If you cache the token across steps spanning more than 1 hour, it expires. Fetch a fresh token at each step that needs credentials.

id-token: write permission missing.

If permissions: is not set in the workflow or job, the runner uses the default read-only permission set, which does not include id-token: write. The ACTIONS_ID_TOKEN_REQUEST_TOKEN variable will be absent and the token request returns 403. Add id-token: write to the permissions: block at the job level (or workflow level if all jobs need it).

Per-tenant federation overrides

By default the issuer signs tokens with the platform claim shape (sub = repo:<org>/<repo>:ref:<ref>, no aud restriction). Two knobs let you pin tighter contracts on a per-tenant basis:

  • allowed_audiences, a hard allowlist of aud values the issuer will sign. Any mint request with aud outside the list is rejected before the JWT is created. Useful when you want to mint tokens for AWS and T Cloud trust policies but block any accidentally-broader audience claim. Empty list = no restriction (platform default).
  • sub_claim_template, a Mustache-shaped template rendered against four fixtures ({{tenant}}, {{repo}}, {{branch}}, {{ref_type}}) at token mint time. Useful when your existing GitHub Actions / Bitbucket Pipelines trust policy expects a different sub shape than the fremforge default.

Reading the current overrides

curl -H "Authorization: Bearer $FREMFORGE_PAT" \
  https://frem.sh/_app/api/v1/orgs/acme/runners/federation
# {
#   "issuer": "https://frem.sh/runner/oidc",
#   "jwks_uri": "https://frem.sh/runner/oidc/.well-known/jwks.json",
#   "tenant_org_slug": "acme",
#   "allowed_audiences": [],
#   "sub_claim_template": null
# }

Pinning allowed_audiences

curl -X PUT -H "Authorization: Bearer $FREMFORGE_PAT" \
  -H "Content-Type: application/json" \
  -d '{
    "allowed_audiences": [
      "sts.amazonaws.com",
      "sts.eu-west-1.amazonaws.com"
    ]
  }' \
  https://frem.sh/_app/api/v1/orgs/acme/runners/federation

After this, any runner-controller attempt to mint a token with aud not in the list returns RunnerFederationAudienceRejectedError and the job fails closed. PAT scope required: runners:write.

Customising sub_claim_template

curl -X PUT -H "Authorization: Bearer $FREMFORGE_PAT" \
  -H "Content-Type: application/json" \
  -d '{
    "sub_claim_template": "tenant:{{tenant}}:repo:{{repo}}:branch:{{branch}}"
  }' \
  https://frem.sh/_app/api/v1/orgs/acme/runners/federation

The four supported substitutions are:

TokenResolves toExample
{{tenant}}The org slugacme
{{repo}}<org>/<repo>acme/api
{{branch}}Branch name (only when ref_type=branch; empty for tags)main
{{ref_type}}branch or tagbranch

Unknown placeholders are left literal in the output, for example, {{nope}} stays as {{nope}} in the rendered sub. This is intentional: a misconfigured template fails loudly at the trust-policy match step rather than silently emitting an empty string.

Resetting to defaults

Send an empty allowed_audiences and null sub_claim_template:

curl -X PUT -H "Authorization: Bearer $FREMFORGE_PAT" \
  -H "Content-Type: application/json" \
  -d '{ "allowed_audiences": [], "sub_claim_template": null }' \
  https://frem.sh/_app/api/v1/orgs/acme/runners/federation

All updates are recorded in the tenant audit log under runner_federation.updated. The body snapshot omits the full sub_claim_template but records whether one was set; full audit trace is in the LTS log for audit_event_emitted.

Scaleway IAM External App

Scaleway is a French-headquartered EU-sovereign cloud with data residency in FR / NL / PL. Their IAM External Apps primitive (GA October 2024) accepts an upstream OIDC IdP and lets you bind workload principals via JWT claims, the same federation shape AWS / GCP / Azure offer.

Step 1: Create an IAM External App

In the Scaleway console: IAM → External Apps → Add an External App. Configure:

  • Issuer URL, the fremforge issuer URL from the wizard (e.g. https://frem.sh/_app/runner/oidc).
  • JWKS URI, the fremforge JWKS URI.
  • Subject mapping, bind on sub. Constrain to one repo if possible (repo:<your-slug>/<repo>:*).
  • Audience, your Scaleway organisation URN (scw:org:<org-id>).

Step 2: Attach a Policy

Attach a Scaleway Policy to the External App granting the actions your CI needs. Scope to a single Project for least-privilege.

Step 3: Use in a workflow

- name: Get Scaleway STS token
  id: scw-token
  uses: frem.sh/mirrors/actions-oidc@v1
  with:
    audience: scw:org:<org-id>
- name: Deploy
  env:
    SCW_ACCESS_KEY: ${{ steps.scw-token.outputs.access_key }}
    SCW_SECRET_KEY: ${{ steps.scw-token.outputs.secret_key }}
  run: ./deploy.sh

The token-exchange endpoint shape is documented in the Scaleway IAM docs, the External App primitive emits short-lived STS-style keys keyed on the OIDC subject.

Generic OIDC broker (Authentik, Keycloak)

For setups where the cloud doesn’t speak workload OIDC directly (legacy SAML-only enterprise targets, on-prem deployers, multi-cloud trust maps maintained centrally), use a broker: Authentik, Keycloak, or any OIDC-RP-with-downstream-RP setup.

When to use a broker

  • You already run Authentik / Keycloak for human SSO and want CI workloads under the same federation surface.
  • You want one trust-policy registration to manage, not N per-cloud. Adding a new cloud is then “add a new Authentik provider,” not “redo the trust policy in each cloud.”
  • Your target is SAML-only (some on-prem regulated systems). The broker bridges OIDC → SAML.
  • You’re targeting a cloud without first-class workload-OIDC (Stackit, OVH, Hetzner), federate via the broker rather than wait for first-class support.

Step 1: Add fremforge as an upstream source

In Authentik: Directory → Federation → OAuth/OIDC Source. Set:

  • OIDC Provider, issuer URL from the wizard.
  • Audience, leave empty or set per your downstream provider.
  • Scopes, openid is sufficient; fremforge tokens carry no profile/email scopes.

The Keycloak equivalent is Identity Providers → OpenID Connect v1.0 with Use JWKS URL enabled.

Step 2: Map claims to local groups / roles

In Authentik: Customisation → Property Mappings → OIDC. Map the runner JWT’s sub (shape repo:<slug>/<repo>:ref:refs/heads/<branch>) onto local groups using a small Python expression:

# Example: anyone deploying main → 'prod-deploy' group
import re
if re.match(r'^repo:[^/]+/[^:]+:ref:refs/heads/main$', token.get('sub', '')):
    return ['prod-deploy']
return []

Step 3: Use the broker as the downstream-cloud issuer

Your cloud’s trust policy then matches on a token signed by Authentik, not by fremforge directly. Audience = the Authentik Provider’s client_id. Exchange the fremforge JWT for an Authentik-signed JWT via Authentik’s /application/o/token/ endpoint:

- name: Get fremforge runner OIDC token
  id: ff-token
  uses: frem.sh/mirrors/actions-oidc@v1
  with: { audience: <authentik-provider-client-id> }
- name: Exchange for Authentik token
  id: ak-token
  run: |
    curl -fsS -X POST https://idp.acme.com/application/o/token/ \
      -d grant_type=urn:ietf:params:oauth:grant-type:token-exchange \
      -d subject_token=${{ steps.ff-token.outputs.token }} \
      -d subject_token_type=urn:ietf:params:oauth:token-type:jwt \
      -d audience=<downstream-rp> \
      | jq -r .access_token > /tmp/ak-token
- name: Deploy
  env:
    BROKER_TOKEN: $(cat /tmp/ak-token)
  run: ./deploy.sh

Trade-off

The broker is one extra hop in the runner→cloud path (more latency, one more place to debug). It pays for itself if you have ≥3 clouds, regulated SAML targets, or already run a broker for human SSO. For single-cloud setups, federate directly to the cloud (tabs above).

Cross-references

  • CI runners, hosted and BYO runner reference
  • Secrets, storing and scoping long-lived credentials
  • Security, runner isolation model and supply-chain security