OIDC token federation
Each fremforge CI job can receive a short-lived OIDC token: an RS256-signed JWT issued by fremforge’s runner-OIDC issuer. Cloud providers (T Cloud Public, 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:
| Claim | Value / format | Example |
|---|---|---|
iss | https://frem.sh/_app/runner/oidc (single global issuer for all tenants) | https://frem.sh/_app/runner/oidc |
sub | repo:<org>/<repo>:ref:refs/heads/<branch> | repo:acme/api:ref:refs/heads/main |
aud | Configurable; default https://frem.sh | https://frem.sh |
repository_owner | Org slug — pin trust-policy conditions on this claim for per-tenant isolation under the shared issuer | acme |
repository | <org>/<repo> | acme/api |
ref | Full git ref | refs/heads/main |
sha | Commit SHA | a1b2c3d4... |
environment | Deployment environment name (if job declares one) | production |
job_workflow_ref | Workflow file path and ref | acme/api/.forgejo/workflows/deploy.yaml@refs/heads/main |
deployer_group | (T Cloud Public only) IAM group name to bind the federated user to, set per-workload by fremforge — see T Cloud Public agency trust | acme-prod-deployers |
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. fremforge 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_ENVMost 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 Public agency trust
T Cloud Public’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 IAM groups, then exchange the runner’s ID token at OTC’s OS-FEDERATION endpoint for a T Cloud Public Keystone token scoped to the groups the mapping bound.
fremforge uses claim-driven group binding (the same shape AWS Identity Center, GCP Workload Identity Federation, and Azure Federated Credentials use): the runner’s ID token carries a deployer_group claim per-workload, and your OTC mapping reads that claim verbatim. One mapping rule covers your entire fleet — adding a workload doesn’t touch your IAM at all.
Step 1: Create an OpenID Connect identity provider
In the T Cloud Public 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 theissclaim on every fremforge runner ID token). - Client ID (
client_id):https://frem.sh, must equal theaudclaim on the runner ID token. - Signing key (
signing_key): paste the JWKS public-key JSON. Fetch it once fromhttps://frem.sh/_app/runner/oidc/.well-known/openid-configuration→jwks_uriand copy the JSON. T Cloud Public 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 the claim-driven mapping rule
One rule, scales for any number of workloads:
[
{
"local": [
{ "user": { "name": "fremforge-{0}-{1}" } },
{ "group": { "name": "{2}" } }
],
"remote": [
{ "type": "repository_owner" },
{ "type": "repository" },
{ "type": "deployer_group" }
]
}
]The trust posture:
- The federated user joins whatever IAM group name the ID token’s
deployer_groupclaim asserts ({2}substitutes the claim value verbatim). - The claim is set by fremforge’s runner-OIDC issuer per the workload→group mapping you agree at onboarding. It’s only assertable for workloads we have a row for, and only for the ref the row pins (default: main branch).
- Same JWT signature pinning, same
iss+aud+repository_ownerfiltering — only the workload→group decision moved from “N OTC mapping rules” to “one claim emitted by fremforge.”
Step 3: Create the IAM groups your workloads will assume
Create one group per deployer profile you want to expose (e.g. acme-prod-deployers, acme-staging-deployers). Attach whatever project + EPS roles each profile needs — those are 100% yours; fremforge never touches your group configuration. Group names match the value fremforge will put in the deployer_group claim, agreed at onboarding.
Step 4: Tell fremforge which workload uses which group
Email or admin-API call: “Repo acme/api, main branch → group acme-prod-deployers.” fremforge inserts a row in its runner_deployer_groups table. From the next workflow run onward, ID tokens for that workload carry deployer_group=acme-prod-deployers and your mapping binds the federated user to that group.
Adding a new repo is just another email — your OTC IAM stays unchanged.
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 Public 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 Administratorscoped to the workload OBS bucket, orCCE FullAccessscoped to the workload project).
Note the agency name and the project where you scope it.
Step 5: Use the credentials in a workflow
The runner fetches an ID token, exchanges it at OTC’s OS-FEDERATION endpoint for an unscoped Keystone token, then swaps for a project-scoped token. Both T Cloud Public CLI (otc) and OpenTofu with the opentelekomcloud provider accept OS_AUTH_TOKEN from the environment with no further configuration.
# Forgejo-specific: opts the task into the OIDC envelope.
# Forgejo jobparser only reads this top-level key — it does NOT
# auto-derive from `permissions: id-token: write` like GitHub does.
enable-openid-connect: true
permissions:
id-token: write
contents: read
jobs:
deploy:
runs-on: fremforge
steps:
- name: Exchange OIDC for T Cloud Public token
run: |
# 1. Fetch the runner OIDC JWT
JWT=$(curl -sS --fail \
-H "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \
"$ACTIONS_ID_TOKEN_REQUEST_URL&audience=https://frem.sh" \
| jq -r .value)
# 2. Exchange at OTC's OS-FEDERATION endpoint (NOT /v3/auth/tokens
# with methods=["id_token"] — OTC's Keystone fork doesn't support
# that. Use the IdP-scoped URL with Bearer header instead).
UNSCOPED=$(curl -sS -i \
-X POST -H "Authorization: Bearer $JWT" \
"https://iam.eu-de.otc.t-systems.com/v3/OS-FEDERATION/identity_providers/fremforge-runner-oidc/protocols/oidc/auth" \
| awk 'BEGIN{IGNORECASE=1} /^x-subject-token:/ { sub(/^x-subject-token:[ \t]*/,"",$0); gsub(/\r/,"",$0); print }')
# 3. Swap for project-scoped (the unscoped token gives you no
# permissions on its own; you need a project scope to do anything).
SCOPED=$(curl -sS -i -X POST \
-H "Content-Type: application/json" \
-d "{\"auth\":{\"identity\":{\"methods\":[\"token\"],\"token\":{\"id\":\"$UNSCOPED\"}},\"scope\":{\"project\":{\"name\":\"eu-de\"}}}}" \
"https://iam.eu-de.otc.t-systems.com/v3/auth/tokens" \
| awk 'BEGIN{IGNORECASE=1} /^x-subject-token:/ { sub(/^x-subject-token:[ \t]*/,"",$0); gsub(/\r/,"",$0); print }')
echo "::add-mask::$SCOPED"
echo "OS_AUTH_TOKEN=$SCOPED" >> $GITHUB_ENV
- name: Tofu plan
run: tofu plan
# opentelekomcloud provider picks up OS_AUTH_TOKEN automaticallyNotes on T Cloud Public specifics
- OTC uses
/v3/OS-FEDERATION/identity_providers/{idp}/protocols/oidc/auth, NOT/v3/auth/tokenswithmethods=["id_token"]. The OpenStack-upstream auth-body method returns400 "Unsupported method: id_token"on OTC’s Keystone fork. Verified empirically + per OTC API dociam_13_0606. - OTC does not auto-rotate JWKS. You paste the signing key once at provider creation; re-paste when fremforge announces a rotation. AWS / Azure / GCP all auto-fetch from the discovery document — only T Cloud Public requires the explicit paste.
- The exchanged token is in the
X-Subject-Tokenresponse header, not the body — OpenStack-Keystone convention. - Workload scoping comes from the
deployer_groupclaim (mapping binds the federated user to that group) AND the project roles on that group. The claim prevents the wrong workload from assuming the wrong group; the role limits what the group can do. - Optional: agencies for assumed-role patterns are also supported, but most use cases don’t need them — the group binding from step 2 is enough.
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
- In the Azure portal, go to Entra ID → App registrations →
<your app>→ Certificates & secrets → Federated credentials. - Click Add credential.
- Scenario: Other issuer.
- 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
- Issuer:
- 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 ./distThe 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-imageThe 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 (
mainvsmaster). - The trust policy uses a tag ref (
refs/tags/v1.0) but the job runs on a branch. - The policy uses
refs/heads/mainbut the job runs on a PR branch (refs/pull/42/head). UseStringLikewith 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-org federation overrides
The tenant-admin page at https://frem.sh/<org>/_admin/runner-federation is the place to read your org’s federation settings and tighten them when needed.
For most setups you only need the issuer URL — copy it from this page into your cloud’s trust policy (the Provider/Issuer URL field for AWS, Azure, GCP, Scaleway, etc.). The page also shows the JWKS URL for providers that don’t auto-fetch the discovery document (T Cloud Public requires the explicit paste; AWS / Azure / GCP fetch it automatically).
Under Advanced overrides on the same page you can:
- Pin
allowed_audiences— a hard allowlist ofaudvalues the issuer will sign. Use it when you want to mint tokens only for specific cloud trust policies and block any accidentally-broader audience. - Set a custom
subclaim template — when your existing trust policy expects a differentsubshape than the fremforge default.
Most customers leave the Advanced overrides at their defaults and use only the issuer URL. The same settings are also available programmatically — see below.
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 ofaudvalues the issuer will sign. Any mint request withaudoutside the list is rejected before the JWT is created. Useful when you want to mint tokens for AWS and T Cloud Public 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 differentsubshape 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/federationAfter this, any attempt to mint a token with aud not in the list is rejected and the job fails closed with an audience-rejected error. 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/federationThe four supported substitutions are:
| Token | Resolves to | Example |
|---|---|---|
{{tenant}} | The org slug | acme |
{{repo}} | <org>/<repo> | acme/api |
{{branch}} | Branch name (only when ref_type=branch; empty for tags) | main |
{{ref_type}} | branch or tag | branch |
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/federationAll 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.
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.shThe 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,
openidis 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.shTrade-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