.png)
Self-Hosted Runners in 2026: ARC, Security, Cost
Every CI pipeline that deploys to a cloud provider needs credentials. For years, the default answer was to generate a long-lived access key in AWS, export a GCP service account JSON file, or create an Azure client secret, then paste it into GitHub's repository secrets. It works. It also creates a static credential that never expires, sits in a settings page anyone with repo admin can read, and gives an attacker a permanent backdoor if it leaks.
OIDC federation replaces that pattern entirely. Instead of storing a secret, your workflow requests a short-lived JWT from GitHub's OIDC provider, presents it to your cloud provider, and receives a temporary access token scoped to a single job run. When the job finishes, the token expires. There's nothing to rotate, nothing to leak, and nothing that persists between runs.
This guide walks through the full setup for all three major cloud providers: AWS IAM, GCP Workload Identity, and Azure federated credentials. Each section includes the cloud-side configuration, the workflow YAML, and the scoping mistakes that trip teams up most often.
A leaked AWS access key gives anyone the ability to assume whatever IAM permissions that key carries. A GCP service account JSON keyfile is a portable credential that works from any machine, not just your CI runner. An Azure client secret with Contributor role on a subscription can spin up resources, exfiltrate data, or modify deployments without any additional authentication.
The breach scenarios aren't theoretical. Credentials end up in logs when a workflow prints environment variables for debugging. They get committed to forks when someone copies a workflow file. They get exposed through Actions artifacts if a step writes them to disk without redaction. And because they're long-lived, the window of exposure stretches from the moment of creation until someone remembers to rotate them, which is often never.
OIDC doesn't just reduce the risk. It eliminates the entire category of "leaked CI credential" incidents.
GitHub runs an OIDC identity provider at https://token.actions.githubusercontent.com. When a workflow job has permissions: id-token: write, the runner can request a JWT from this provider. That JWT contains claims that identify exactly which repository, branch, environment, and workflow triggered the request.
The key claims in the token:
repo:my-org/my-repo:ref:refs/heads/main. This is the primary claim cloud providers match against.https://token.actions.githubusercontent.com. Cloud providers use this to fetch GitHub's public signing keys.The flow is straightforward: your workflow requests the JWT, passes it to a cloud provider's login action, the provider validates the token signature and claims against its trust policy, and issues a short-lived cloud credential. The entire exchange happens in seconds, and the resulting credential is valid only for the duration of the job.
AWS requires two things on the cloud side: an OIDC identity provider registered in IAM, and an IAM role with a trust policy that validates the GitHub JWT claims.
In the AWS Console, go to IAM > Identity Providers > Add Provider. Select OpenID Connect and enter:
https://token.actions.githubusercontent.comsts.amazonaws.comYou only need to do this once per AWS account. The same OIDC provider can be referenced by multiple IAM roles.
Create an IAM role with a trust policy that restricts which repositories and branches can assume it. This is where scoping matters. A trust policy without a sub condition lets any repository in your GitHub organization assume the role, which defeats the purpose.
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"Federated": "arn:aws:iam::123456789012:oidc-provider/token.actions.githubusercontent.com"
},
"Action": "sts:AssumeRoleWithWebIdentity",
"Condition": {
"StringEquals": {
"token.actions.githubusercontent.com:aud": "sts.amazonaws.com",
"token.actions.githubusercontent.com:sub": "repo:my-org/my-repo:ref:refs/heads/main"
}
}
}
]
}If you use GitHub environments, the subject format changes to repo:my-org/my-repo:environment:production. You can also use StringLike with wildcards for broader matching, but be deliberate about it. repo:my-org/my-repo:* allows any branch and any PR merge ref, which may be broader than you want for production deployments.
name: Deploy to AWS
on:
push:
branches: [main]
permissions:
id-token: write
contents: read
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789012:role/github-actions-deploy
aws-region: us-east-1
- name: Deploy
run: aws s3 sync ./dist s3://my-app-bucket/The permissions block is non-negotiable. Without id-token: write, the runner can't request the JWT and the authentication step fails silently or with a cryptic 403. This is the single most common misconfiguration teams hit when adopting OIDC.
GCP uses Workload Identity Federation, which involves three objects: a Workload Identity Pool, a Provider within that pool, and a service account binding. The concept is the same as AWS (validate a JWT, issue a short-lived token), but the configuration is spread across more resources.
gcloud iam workload-identity-pools create github-pool \
--project="my-project-id" \
--location="global" \
--display-name="GitHub Actions Pool"gcloud iam workload-identity-pools providers create-oidc github-provider \
--project="my-project-id" \
--location="global" \
--workload-identity-pool="github-pool" \
--display-name="GitHub OIDC" \
--attribute-mapping="google.subject=assertion.sub,attribute.repository=assertion.repository" \
--issuer-uri="https://token.actions.githubusercontent.com" \
--attribute-condition="assertion.repository_owner == 'my-org'"The attribute-condition flag is critical. Without it, any GitHub repository could authenticate through your pool. At minimum, restrict by repository_owner. For tighter control, add assertion.repository == 'my-org/my-repo'.
gcloud iam service-accounts add-iam-policy-binding \
deploy@my-project-id.iam.gserviceaccount.com \
--project="my-project-id" \
--role="roles/iam.workloadIdentityUser" \
--member="principalSet://iam.googleapis.com/projects/PROJECT_NUMBER/locations/global/workloadIdentityPools/github-pool/attribute.repository/my-org/my-repo"name: Deploy to GCP
on:
push:
branches: [main]
permissions:
id-token: write
contents: read
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- id: auth
name: Authenticate to GCP
uses: google-github-actions/auth@v2
with:
workload_identity_provider: projects/123456789/locations/global/workloadIdentityPools/github-pool/providers/github-provider
service_account: deploy@my-project-id.iam.gserviceaccount.com
- name: Deploy
run: gcloud run deploy my-service --source .Same rule as AWS: the permissions: id-token: write block must be present. GCP's google-github-actions/auth action needs the ACTIONS_ID_TOKEN_REQUEST_TOKEN environment variable that this permission enables. Without it, the action can't exchange the JWT.
Azure's approach uses Microsoft Entra ID (formerly Azure AD) federated identity credentials. You create an App Registration, add a federated credential that trusts GitHub's OIDC provider, then use the azure/login action to authenticate.
In the Azure portal, navigate to Microsoft Entra ID > App Registrations > New Registration. Give it a name like "GitHub Actions Deploy" and register it. Note the Application (client) ID and Directory (tenant) ID.
Under your App Registration, go to Certificates & Secrets > Federated Credentials > Add Credential. Select "GitHub Actions deploying Azure resources" as the scenario, then configure:
api://AzureADTokenExchange (the default)Azure's UI makes the entity scoping explicit, which is actually easier to get right than AWS or GCP. Each federated credential maps to exactly one subject claim pattern, so you create one credential per branch or environment you want to authorize.
Grant the App Registration's service principal the RBAC role it needs. Scope it to the specific resource group rather than the entire subscription whenever possible. A Contributor role at the subscription level lets the workflow do almost anything in your Azure account. A Contributor role on a single resource group limits the blast radius.
name: Deploy to Azure
on:
push:
branches: [main]
permissions:
id-token: write
contents: read
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Azure Login
uses: azure/login@v2
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
- name: Deploy
run: |
az webapp deploy --resource-group my-rg --name my-app --src-path ./build.zipNotice there's no client secret in the workflow. The azure/login action detects that OIDC is available (because id-token: write is set) and uses the federated credential flow automatically. The client ID, tenant ID, and subscription ID aren't secrets in the traditional sense; they're identifiers, not credentials. Even if someone reads them, they can't authenticate without a valid GitHub OIDC token from the right repo and branch.
OIDC is only as secure as the trust policy you write. Here are the mistakes that show up repeatedly:
sts.amazonaws.com) is the same for everyone.repo:org/repo:* means any branch, any PR, and any tag can deploy to production. Lock production roles to ref:refs/heads/main or an environment claim.id-token: write in the workflow YAML. The error messages from the cloud provider login actions don't always make it obvious that the problem is on the GitHub side, not the cloud side.OIDC tokens are short-lived by design, but the credential your workflow receives from the cloud provider might persist on the runner's filesystem for the duration of the job. If you're running on persistent self-hosted runners, that credential file could survive into the next job if cleanup isn't thorough.
Ephemeral runners eliminate this risk entirely. Each job gets a fresh VM that's destroyed when the job completes. Tenki runners work this way: the VM spins up, runs the job, and gets torn down afterward. Even if a job fails mid-step and leaves a credential file on disk, that disk is gone. There's no cross-job leakage, no stale tokens, no cleanup scripts to maintain.
This matters more than people think. The GCP auth action writes a credentials file to disk that gcloud reads. The AWS action sets environment variables that downstream tools consume. On a persistent runner, these survive unless you explicitly clear them. On an ephemeral runner, they can't leak because the environment stops existing.
The cloud-side configuration is only half the picture. The workflow YAML itself is where most mistakes happen, and they're easy to miss in review because YAML is dense and the security implications of a missing line aren't visually obvious.
The most common workflow-level errors with OIDC:
permissions: id-token: write at the workflow level when it's only needed by one job, inadvertently granting the permission to all jobsarn:aws:iam::*:role/admin in the configure-credentials stepcontents: read alongside id-token: write, which causes actions/checkout to fail because the explicit permissions block overrides the defaultsThese are the kinds of issues that Tenki's code reviewer catches during PR review. It understands workflow YAML structure well enough to flag missing permission blocks, overly broad role references, and audience mismatches before they land on main. Automating that check matters because human reviewers reliably overlook YAML security details in large PRs.
The pattern is the same across all three providers: register GitHub's OIDC issuer, create a trust relationship with subject-claim conditions, update your workflow with the right permissions block, and use the provider's official login action. The specifics differ (AWS uses IAM trust policies, GCP uses Workload Identity Pools, Azure uses Entra ID federated credentials), but the security model is identical.
If you're still using static access keys or service account JSON files in your CI workflows, the migration path is clear and the security improvement is immediate. Set up OIDC, delete the old secrets, and stop worrying about credential rotation schedules that nobody actually follows.
Tags
Recommended for you
What's next in your stack.