Deploy secrets: OIDC federation
fremforge doesn’t want to hold your production secrets. Long-lived deploy creds (AWS_ACCESS_KEY_ID, AZURE_CLIENT_SECRET, GCP_SA_KEY, …) stored in CI secret backends are a perpetual rotation chore and a perpetual breach blast-radius. The modern shape is OIDC federation: your workflow asks the CI server for a short-lived identity token, your cloud’s IAM verifies the token and hands back a short-lived credential. fremforge ships this end-to-end.
How it works
Every CI job that runs on fremforge gets a freshly-minted JSON Web Token signed by fremforge’s runner OIDC issuer at https://frem.sh/_app/runner/oidc. The token carries claims describing the job context, repository, ref, workflow, optionally environment, and your cloud IAM trust policy decides whether to grant credentials based on those claims.
sequenceDiagram
autonumber
actor User
participant FF as fremforge<br/>runner-OIDC issuer
participant Pod as runner Pod
participant Cloud as Your cloud's<br/>IAM • STS / WIF / …
User->>FF: git push
FF->>Pod: dispatch job — mint OIDC JWT
Pod->>Cloud: "Here's my JWT, give me credentials"
Cloud->>Cloud: Verify JWT against fremforge JWKS<br/>+ check trust policy claims
Cloud->>Pod: 1h credentials
Pod->>Cloud: deploy / read / write
Trust-policy claims your cloud can match on:
| Claim | Example | What it identifies |
|---|---|---|
iss | https://frem.sh/_app/runner/oidc | fremforge itself |
aud | sts.amazonaws.com | what cloud you’re authenticating to |
sub | repo:my-org/my-repo:environment:production (when environment: is declared)repo:my-org/my-repo:ref:refs/heads/main (when no env) | which workflow run |
repository | my-org/my-repo | repo name |
repository_owner | my-org | org slug |
ref | refs/heads/main | git ref |
environment | production | workflow-declared env name |
actor | alice | Forgejo username that triggered the run |
These mirror GitHub Actions’ claim shape, so existing trust policies and the actions/configure-aws-credentials / azure/login / google-github-actions/auth actions work without modification.
Discovery URL
Point your cloud at:
- Issuer URL:
https://frem.sh/_app/runner/oidc - JWKS URL:
https://frem.sh/_app/runner/oidc/.well-known/jwks.json - Discovery doc:
https://frem.sh/_app/runner/oidc/.well-known/openid-configuration
All three are anonymous-readable.
Workflow patterns
Declaring an environment
jobs:
deploy:
runs-on: fremforge-runner
environment: production
permissions:
id-token: write # required to mint the OIDC JWT
contents: read
steps:
- uses: actions/checkout@v4
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789012:role/fremforge-deploy-prod
aws-region: eu-central-1
- run: aws s3 sync ./build s3://my-prod-bucket/The environment: production reshapes the OIDC sub claim to repo:my-org/my-repo:environment:production, so your trust policy can grant access only to production-declared jobs.
Without an environment
When you don’t declare environment:, the sub falls back to repo:<owner>/<repo>:ref:<ref>. Trust policies that match on ref work the same way as on GitHub. The fremforge-side environments gate doesn’t apply (it only gates env-declared workflows), so the cloud-side trust policy is your only line.
Trust policy examples
AWS
IAM trust policy for an OIDC-federated role:
{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Principal": {
"Federated": "arn:aws:iam::123456789012:oidc-provider/frem.sh/_app/runner/oidc"
},
"Action": "sts:AssumeRoleWithWebIdentity",
"Condition": {
"StringEquals": {
"frem.sh/_app/runner/oidc:aud": "sts.amazonaws.com"
},
"StringLike": {
"frem.sh/_app/runner/oidc:sub": "repo:my-org/my-repo:environment:production"
}
}
}]
}One-time setup: register fremforge as an OIDC provider:
aws iam create-open-id-connect-provider \
--url https://frem.sh/_app/runner/oidc \
--client-id-list sts.amazonaws.com \
--thumbprint-list $(curl -s https://frem.sh/_app/runner/oidc/.well-known/jwks.json | jq -r '.keys[0].x5t')Azure (Workload Identity Federation)
Register the federated credential on an Entra ID App Registration:
az ad app federated-credential create \
--id $APP_OBJECT_ID \
--parameters '{
"name": "fremforge-prod-deploy",
"issuer": "https://frem.sh/_app/runner/oidc",
"subject": "repo:my-org/my-repo:environment:production",
"audiences": ["api://AzureADTokenExchange"]
}'Workflow:
- uses: azure/login@v2
with:
client-id: ${{ vars.AZURE_CLIENT_ID }}
tenant-id: ${{ vars.AZURE_TENANT_ID }}
subscription-id: ${{ vars.AZURE_SUBSCRIPTION_ID }}GCP (Workload Identity Federation)
gcloud iam workload-identity-pools providers create-oidc fremforge-provider \
--workload-identity-pool=fremforge-pool \
--issuer-uri="https://frem.sh/_app/runner/oidc" \
--attribute-mapping="google.subject=assertion.sub,attribute.repository=assertion.repository,attribute.environment=assertion.environment" \
--attribute-condition="attribute.environment == 'production'"Workflow:
- uses: google-github-actions/auth@v2
with:
workload_identity_provider: projects/PROJECT_NUMBER/locations/global/workloadIdentityPools/fremforge-pool/providers/fremforge-provider
service_account: deploy@my-project.iam.gserviceaccount.comT Cloud (Open Telekom Cloud / OTC IAM Identity Provider)
T Cloud uses IAM Identity Providers + token-exchange:
- Identity Provider in OTC Console → IAM → Identity Providers → Create:
- Name:
fremforge-runner-oidc - Protocol:
OpenID Connect - Issuer URL:
https://frem.sh/_app/runner/oidc - JWKS URL:
https://frem.sh/_app/runner/oidc/.well-known/jwks.json - Client ID:
iam.eu-de.otc.t-systems.com(or your project’s STS audience)
- Name:
- Mapping rules, translate claims into a federated user. Example mapping a
repo:my-org/my-repo:environment:productionsubject to the OTC groupdeploy-prod:
[
{
"local": [
{ "user": { "name": "{0}" } },
{ "group": { "name": "deploy-prod" } }
],
"remote": [
{ "type": "sub" },
{ "type": "environment", "any_one_of": ["production"] }
]
}
]- Workflow, exchange the JWT for an OTC token via the IAM token-exchange endpoint, then use the returned
X-Subject-Tokenfor any OTC API call.
Gating which refs can deploy where
OIDC federation removes the need to STORE long-lived secrets, but you still need to decide which branches can deploy to which environment. A workflow on a feature branch CAN mint a JWT claiming environment: production; the gate is what stops the JWT from being honoured.
fremforge offers two gates, and you should use both as defense in depth.
Gate 1: cloud-side trust policy (always required)
Your cloud IAM trust policy is the final authority. Pin the sub claim to the repo+environment shape AND pin the ref claim to the branch(es) that env is allowed to deploy from. Example AWS:
{
"Condition": {
"StringEquals": {
"frem.sh/_app/runner/oidc:sub": "repo:my-org/my-repo:environment:production"
},
"StringLike": {
"frem.sh/_app/runner/oidc:ref": "refs/heads/main"
}
}
}A feature branch CAN mint the JWT, but AWS refuses to issue credentials because ref is refs/heads/feature/foo instead of refs/heads/main. This is the GitHub-recommended baseline pattern and works on day one.
Gate 2: fremforge-side per-environment deployment-branch patterns (recommended)
At Settings → Environments, configure each environment GitHub-style. Three sections per environment:
Deployment branches and tags, glob patterns matched against the triggering git ref. Examples (auto-prefixed with refs/heads/ for branch shorthand):
| Name | Patterns | Effect |
|---|---|---|
production | main | main branch only |
staging | maindeveloprelease/* | main + develop + release/* branches |
pr-preview | * | any branch (cloud-side gate is the real authority) |
release | refs/tags/v[0-9]+.* | version tags only (explicit refs/tags/ prefix) |
Workflows that declare environment: production are refused at dispatch when the triggering ref doesn’t match. Workflows WITHOUT an environment: declaration run unrestricted, basic CI on feature branches keeps working.
Environment secrets, encrypted, env-scoped, injected as workflow ${{ secrets.NAME }} AND plain env vars when the workflow’s environment: matches. Covers the non-cloud-IAM secret cases (third-party API keys, signing keys, webhook secrets) that OIDC federation doesn’t solve.
Environment variables, non-secret env-scoped values. Same injection model.
What about approval gates?
GitHub Actions Environments has a “Required reviewers” feature: a job pauses post-merge until N people click Approve in the Actions UI. fremforge intentionally doesn’t ship this because Forgejo already has a stronger gate at the same point in the timeline, branch protection on main with CODEOWNERS on .forgejo/workflows/* requires PR review before any deploy-capable workflow lands. For the 99% case (1 PR = 1 deploy), Forgejo’s PR review IS the deploy approval. Customers needing the GitHub case (1 PR can produce N independent deploys with separate approvers per deploy), file an issue and we’ll prioritise Phase 2.5 deploy-time approvers based on actual demand.
The fremforge-side gate stops the JWT from being minted at all. Cloud-side gate stops a minted JWT from being honoured. Combine both: even if a future fremforge bug let a refused dispatch through, the cloud’s IAM still refuses to issue credentials. Even if a customer’s cloud trust policy has a typo and accepts a broader ref pattern than intended, fremforge still refused to mint the JWT.
Lazy onboarding
When you declare environment: x on a workflow but haven’t yet configured an environments row for x, fremforge allows the dispatch rather than refusing it. This is deliberate so a fresh customer’s workflows don’t break the moment they declare an environment, your cloud-side trust policy is the safety net until you add the fremforge-side row. Once a row exists, the gate is enforced.
Lock-down mode: actions_only_protected_branches
For high-security environments where you want to forbid CI execution on unprotected refs ENTIRELY (not just gate deploy creds), enable Settings → Security & policies → Actions deploy gate:
Only run workflows on protected branches
When enabled, the runner-controller refuses to dispatch ANY workflow whose triggering branch is NOT in the repo’s protected-branch list (configured via Forgejo’s native Settings → Branches → Add protection rule). This includes basic CI (tests, lint), nothing runs on unprotected branches. Useful for regulated environments where you want zero code execution outside reviewed refs; not recommended for normal dev flow because it breaks feature-branch testing.
If you enable this, you’ll typically protect main, develop, release/* patterns in Forgejo to keep your standard dev branches CI-eligible.
Recommended posture
For typical SaaS / product teams:
- Don’t enable
actions_only_protected_branches, let basic CI run on feature branches. - Do configure environments at Settings → Environments with appropriate
allowed_refs_regexfor each (production, staging, etc.). - Do configure cloud trust policies with both
sub(env-pinned) ANDref(branch-pinned) conditions. - Branch-protect
mainin Forgejo with CODEOWNERS coverage on.forgejo/workflows/*so workflow changes need review.
For regulated / lock-down environments (banking, defence, healthcare):
- Do enable
actions_only_protected_branches. - Do configure environments for granular per-env gating on top.
- Same cloud-side trust policy posture as above.
This combination matches the deploy-gate model GitHub Enterprise + AWS recommend (require-environments-on-deploy + branch-protected-only) plus the optional lock-down switch for regulated tenants.
Comparison: what fremforge stores vs your cloud
| Long-lived secrets | OIDC federation (this page) | |
|---|---|---|
| Where deploy creds live | Settings → Secrets in fremforge | Your cloud’s IAM. fremforge never sees them. |
| Credential lifetime | Until you rotate | 1 hour (configurable per-trust-policy) |
| If fremforge is breached | Attacker holds your AWS keys | Attacker can mint OIDC JWTs but cloud-side trust policy bounds what those JWTs can do (per-env, per-repo, per-ref) |
| Rotation chore | Quarterly per secret per cloud | None, keys rotate on each job |
| Audit trail | Manual: who exported the secret when | Every OIDC mint is in audit_events + your cloud’s CloudTrail/Activity Log |
Limitations
- Object-form
environment:(withname:+url:) is supported by name;url:is parsed but not currently surfaced as a claim. - Matrix-expanded environment names (
environment: ${{ matrix.target }}) are not resolved at OIDC mint time and result in noenvironmentclaim being emitted. Use a hardcodedenvironment:per matrix job if you need env-claim-based trust policies. - Per-environment required-approver gates (à la GitHub Environments’ “wait for approval” feature) are tracked as Phase 2 follow-up; today’s enforcement is binary (protected-branch yes/no).
See also
- Audit chain integrity, every OIDC mint emits an audit event into the per-tenant hash chain
- Hosted runner model, what isolates one customer’s runner from another