AWS OIDC Warden: Validates GitHub Actions JWT tokens for secure, fine-grained AWS access control.
10K+

AWS OIDC Warden is a secure, lightweight Go service that validates OIDC tokens (e.g. GitHub Actions) and exchanges them for short-lived AWS credentials via STS AssumeRole. It acts as a trusted intermediary between CI/CD workflows and AWS resources, enforcing fine-grained access control based on repository, branch, actor, and other configurable constraints — without storing long-lived credentials.
Caution
Not all OIDC claims can be trusted. See the great tool and table created [PaloAltoNetworks/GitHub OIDC Utils](https://github.com/PaloAltoNetworks/github-oidc-utils) for a comprehensive list of claims.This lambda allows you to include specific constraints for a repository before it can obtain credentials from a role. Choose wisely based on the table that Palo Alto Networks has provided in the repository linked above.
| Document | What's inside |
|---|---|
| docs/TOKEN_VALIDATION.md | How token validation works — the security core: modes, JWKS, crypto hardening, claim checks, SSRF protection |
| docs/MULTI_ISSUER.md | Onboard any OIDC provider — discovery, provider, claim_mappings, per-issuer audiences |
| docs/CONFIGURATION.md | Full config reference — all keys, env vars, remote S3 reload, cache, session policies |
| docs/ARCHITECTURE.md | Component diagram, request pipeline, deployment options, full build/deploy commands |
| docs/SESSION_TAGGING.md | Per-issuer session tags applied to every STS call, ABAC patterns |
| docs/TAG_BASED_AUTHORIZATION.md | Tag-based authorization, hub/spoke cross-account model |
| docs/LOGGING.md | Structured logging, durable audit trail, audit_required, SIEM signals, alerts |
| docs/MIGRATION_V2.md | Upgrading from v1 (single-issuer) to the v2 issuers[] model — breaking-change checklist |
issuers[]); GitHub Actions has native support, and provider: generic onboards any OIDC IdP by mapping its claims — see docs/MULTI_ISSUER.mdnone/HS*), kid+alg+key-type key pinning, RSA≥2048 / EC on-curve checks, SSRF-safe JWKS fetching, and bounded time/size — full detail in docs/TOKEN_VALIDATION.mdjwt_validation.mode: self/apigw/alb)conditions on any verified claim (branch/actor/event/workflow/environment + arbitrary claims), all AND-edaudit_required mode — see docs/LOGGING.mdrole_mappings, and session policies in S3 without redeploying — the Lambda picks up changes within the configured interval, fail-safe on a bad reloadClone the repository:
git clone [email protected]:boogy/aws-oidc-warden.git
cd aws-oidc-warden
Install dependencies:
go mod tidy
Build the binary:
make build
Alternative methods:
ghcr.io/boogy/aws-oidc-warden:latest (see Deployment)make ko-buildAWS OIDC Warden reads config from environment variables (AOW_ prefix), a YAML/JSON/TOML file, or an S3 object. A minimal example:
issuers:
- issuer: https://token.actions.githubusercontent.com
provider: github
audiences:
- sts.amazonaws.com
cache:
type: dynamodb
ttl: 1h
dynamodb_table: aws-oidc-warden-cache
role_mappings:
- subject: "my-org/my-repo"
roles:
- arn:aws:iam::123456789012:role/github-actions-role
conditions:
branch: "refs/heads/main"
v2 note: the top-level
issuer/audiencesandrepo_role_mappings/constraintskeys from v1 were replaced byissuers[],role_mappings, andconditions. See docs/MIGRATION_V2.md.
For the full reference — all keys, condition fields, session-policy options, remote S3 hot-reload, multi-issuer setup, and tag-auth config — see docs/CONFIGURATION.md, docs/MULTI_ISSUER.md, and example-config.yaml.
The wire contract depends on jwt_validation.mode — specifically, who verifies the token. The role ARN is always in the JSON body; the token's location differs.
| Mode | Authorization header | Request body | Token verified by |
|---|---|---|---|
self (default) | none | {"token": "...", "role": "..."} | This service |
apigw | Authorization: Bearer <token> | {"role": "..."} | API Gateway JWT Authorizer |
alb | none — ALB injects x-amzn-oidc-data | {"role": "..."} | ALB OIDC |
The token is never sent twice. In
apigwmode it lives only in theAuthorizationheader — atokenfield in the body is ignored (ParseRoleOnlyRequestBodyreads onlyrole), and a missing header makes API Gateway reject the call before this service runs. See docs/TOKEN_VALIDATION.md §2.1.
Self mode (default) — POST the OIDC token and role ARN in the body:
{
"token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...",
"role": "arn:aws:iam::123456789012:role/github-actions-role"
}
apigw mode — send the token as a Bearer header (API Gateway validates it); the body carries only the role:
POST /verify HTTP/1.1
Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...
Content-Type: application/json
{"role": "arn:aws:iam::123456789012:role/github-actions-role"}
# Start local development server (default port 8080)
make run
# With custom options
go run cmd/local/main.go -port 9090 -config example-config.yaml -log-level debug
The local server loads config at startup from a static provider — there is no live S3 hot-reload locally. Hot-reload is a Lambda deployment feature; see docs/CONFIGURATION.md.
Endpoints:
POST /verify — token validation (matches Lambda behavior)GET /health — health checkThe recommended approach uses @actions/core to request an OIDC token with a specific audience. The example below targets self mode (token in the body). For apigw mode, see the variant that follows.
name: AWS Deployment
jobs:
deploy:
runs-on: ubuntu-latest
permissions:
id-token: write
contents: read
steps:
- uses: actions/checkout@v4
- name: Get AWS credentials via OIDC warden
uses: actions/github-script@v7
with:
script: |
const core = require('@actions/core');
const token = await core.getIDToken('sts.amazonaws.com');
const response = await fetch('https://your-api-gateway-url.execute-api.region.amazonaws.com/prod/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
token: token,
role: 'arn:aws:iam::123456789012:role/github-actions-role'
})
});
const { data } = await response.json();
core.setSecret(data.AccessKeyId);
core.setSecret(data.SecretAccessKey);
core.setSecret(data.SessionToken);
core.exportVariable('AWS_ACCESS_KEY_ID', data.AccessKeyId);
core.exportVariable('AWS_SECRET_ACCESS_KEY', data.SecretAccessKey);
core.exportVariable('AWS_SESSION_TOKEN', data.SessionToken);
- name: Use AWS credentials
run: aws sts get-caller-identity
curl alternative: You can also call the endpoint directly via
curlusing$ACTIONS_ID_TOKEN_REQUEST_URL. The@actions/coremethod above is preferred for cleaner audience control.
apigw mode variant — send the token as an Authorization: Bearer header (API Gateway's JWT Authorizer validates it) and put only the role in the body:
- name: Get AWS credentials via OIDC warden (apigw mode)
uses: actions/github-script@v7
with:
script: |
const core = require('@actions/core');
const token = await core.getIDToken('sts.amazonaws.com');
const response = await fetch('https://your-api-gateway-url.execute-api.region.amazonaws.com/prod/verify', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
},
body: JSON.stringify({
role: 'arn:aws:iam::123456789012:role/github-actions-role'
})
});
const { data } = await response.json();
core.setSecret(data.AccessKeyId);
core.setSecret(data.SecretAccessKey);
core.setSecret(data.SessionToken);
core.exportVariable('AWS_ACCESS_KEY_ID', data.AccessKeyId);
core.exportVariable('AWS_SECRET_ACCESS_KEY', data.SecretAccessKey);
core.exportVariable('AWS_SESSION_TOKEN', data.SessionToken);
The audience requested by getIDToken(...) must match the audience configured on both the API Gateway JWT Authorizer and this service's issuer.
Four Lambda variants are available — API Gateway REST v1 (recommended for production, self mode), API Gateway HTTP v2 (apigw delegated mode), Lambda URLs (simple setups), and ALB (high traffic). All share the same core logic; only the entry point differs.
Quickstart with pre-built container images (recommended):
# API Gateway (REST v1) variant — self mode
aws lambda create-function \
--function-name aws-oidc-warden \
--package-type Image \
--code ImageUri=ghcr.io/boogy/aws-oidc-warden:latest \
--role arn:aws:iam::ACCOUNT:role/lambda-execution-role
# API Gateway (HTTP v2) variant — apigw mode (JWT Authorizer)
aws lambda create-function \
--function-name aws-oidc-warden-apigwv2 \
--package-type Image \
--code ImageUri=ghcr.io/boogy/aws-oidc-warden:apigatewayv2-latest \
--role arn:aws:iam::ACCOUNT:role/lambda-execution-role
# ALB variant
aws lambda create-function \
--function-name aws-oidc-warden-alb \
--package-type Image \
--code ImageUri=ghcr.io/boogy/aws-oidc-warden:alb-latest \
--role arn:aws:iam::ACCOUNT:role/lambda-execution-role
# Lambda URL variant
aws lambda create-function \
--function-name aws-oidc-warden-lambdaurl \
--package-type Image \
--code ImageUri=ghcr.io/boogy/aws-oidc-warden:lambdaurl-latest \
--role arn:aws:iam::ACCOUNT:role/lambda-execution-role
For full build commands, ECR pull-through cache setup, and infrastructure details see docs/ARCHITECTURE.md.
Tag-based authorization lets a repository assume an IAM role authorized by tags on the role itself, without listing the role in role_mappings. This is especially useful when roles are managed across many accounts or teams: add aow/subject (or the legacy aow/repo), aow/ref, and similar tags to the IAM role and the warden will evaluate them against the OIDC claims.
For cross-account (hub/spoke) scenarios, enable the separate top-level cross_account block: the warden reads and assumes roles in member accounts by first assuming a convention-named spoke role (aow-spoke by default) in the target account. The transport is independent of tag-auth — explicit role_mappings can target member-account ARNs on their own. Explicit role_mappings are always evaluated first; tag-auth is a fallback path only.
Both features are opt-in (tag_auth.enabled / cross_account.enabled, default false); cross-account supports a target-account allow-list and an external ID for spoke-role trust, and tag-auth supports transitive session tags. See docs/TAG_BASED_AUTHORIZATION.md for setup, tag reference, and IAM examples, and docs/examples/cross-account/ for a full worked cross-account example (config + IAM roles + StackSets template).
{
"success": true,
"statusCode": 200,
"requestId": "12258876-a981-452b-a7ae-415f8fa737b6",
"processingMs": 254,
"message": "Token validation successful and role assumed",
"data": {
"AccessKeyId": "ASIA1234567890EXAMPLE",
"SecretAccessKey": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
"SessionToken": "FwoGZXIvYXdzEPH//////////wEaDKLZ3MQOJZBKxR1JDiLBARJhUlx1g09xLW+oIYHDt15IZY4...",
"Expiration": "2023-09-29T20:31:14Z"
}
}
{
"success": false,
"statusCode": 403,
"requestId": "12258876-a981-452b-a7ae-415f8fa737b6",
"processingMs": 383,
"message": "Permission denied for the requested operation",
"errorCode": "permission_denied"
}
Error responses carry only the classified errorCode/message; internal
error detail stays in the server-side logs, correlatable via requestId.
.* (patterns are auto-anchored ^(?:...)$)conditions for sensitive roles, and session policies to scope AWS permissionsaudit_required for a fail-closed durable trail — see docs/LOGGING.md)Per-issuer session tags are attached to every STS session for auditing, cost allocation, and ABAC — see docs/SESSION_TAGGING.md.
iss selects the configured issuer spec (routing only — never trusted for identity)kid+alg+key-type–pinned keynbf/iat, lifetime/age caps, and required_claims checked — all fail-closedclaim_mappings.subject / GitHub repository), never self-assertedrole_mappings, then evaluated against auto-anchored regex conditions; a tag-auth fallback can authorize via IAM role tagsToken validation is the security core of the service. The fail-closed validation pipeline (self mode):
For the full step-by-step flow, crypto hardening, JWKS handling, and SSRF protection see docs/TOKEN_VALIDATION.md.
id-token: write; verify the repository name matches your configured patterns and the issuer/audience settings.max_local_size for high traffic.cross_account.enabled: true, ensure the spoke role (aow-spoke by default) exists in each member account and trusts the hub Lambda role, grant iam:GetRole, and list the target account in cross_account.allowed_accounts. See docs/TAG_BASED_AUTHORIZATION.md and docs/examples/cross-account/.git clone [email protected]:your-username/aws-oidc-warden.gitgit checkout -b feature/your-feature-namemake checkTip
If you find a bug please don't just create an issue. Create a pull request with your fix so that everyone can benefit from it.
sts:AssumeRole, sts:TagSession), read role tags for tag-auth (iam:GetRole), DynamoDB cache access, S3 read/write (logs/policies), and CloudWatch LogsTip
A generic role with broader privileges can be given to Lambda, then scoped per-repo with session policies. This reduces the total number of IAM roles needed.
For the complete IAM policy and infrastructure details, see docs/ARCHITECTURE.md.
This project is licensed under the Apache License 2.0.
Content type
Image
Digest
sha256:72cbb962c…
Size
5.8 MB
Last updated
5 days ago
docker pull boogy/aws-oidc-warden