This guide demonstrates how to migrate from storing Vault tokens in terraform.tfvars to using GitHub OIDC (OpenID Connect) for secure, secretless authentication in CI/CD pipelines.
Benefits:
- π Secretless Authentication: No more storing long-lived tokens in files or secrets
- π― Least Privilege: Granular policies scoped to specific paths and operations
- π Automatic Rotation: Short-lived tokens (1 hour TTL) generated per workflow run
- π Role-Based Access: Different permissions for PRs vs production deployments
- π Better Audit Trail: GitHub actor tied to Vault authentication
terraform.tfvars:
vault_token = "hvs.CAExxxxxxxxxxxU"provider.tf:
provider "vault" {
address = "https://vault.k3s.internal.mydomain.fr/"
token = var.vault_token
}Vault Policy:
# Root-like access
path "*" {
capabilities = ["create", "update", "read", "delete", "list", "sudo"]
}Problems:
- Long-lived, high privileged and static tokens
- Root-level access to all Vault paths
- Token rotation requires manual updates and reminders
- Difficult to track who performed actions
# Enable JWT auth method
vault auth enable jwt
# Configure GitHub OIDC
vault write auth/jwt/config \
bound_issuer="https://token.actions.githubusercontent.com" \
oidc_discovery_url="https://token.actions.githubusercontent.com"
# Create role for main branch (production)
vault write auth/jwt/role/terraform-github-actions \
role_type="jwt" \
bound_audiences="https://vault.k3s.internal.mydomain.fr" \
bound_claims='{"repository":"vchatela/homelab"}' \
bound_subject="repo:vchatela/homelab:ref:refs/heads/main" \
user_claim="actor" \
policies="terraform-admin" \
ttl="1h"
# Create role for pull requests (read-only plan)
vault write auth/jwt/role/terraform-github-actions-pr \
role_type="jwt" \
bound_audiences="https://vault.k3s.internal.mydomain.fr" \
bound_claims='{"repository":"vchatela/homelab"}' \
user_claim="actor" \
policies="terraform-admin" \
ttl="1h"# Allow full access to secrets stored under secret/
path "secret/data/*" {
capabilities = ["create", "update", "read", "delete", "list"]
}
# Allow read access to secret metadata (KV v2)
path "secret/metadata/*" {
capabilities = ["create", "update", "read", "delete", "list"]
}
# Allow management of Kubernetes auth backend
path "auth/kubernetes/config" {
capabilities = ["create", "update", "read", "delete"]
}
path "auth/kubernetes/role/*" {
capabilities = ["create", "update", "read", "delete", "list"]
}
# Allow creating/updating policies for apps
path "sys/policies/acl/*" {
capabilities = ["create", "update", "read", "delete", "list"]
}
# Allow managing auth backends
path "sys/auth/kubernetes" {
capabilities = ["create", "update", "read", "delete"]
}
path "sys/auth/kubernetes/*" {
capabilities = ["create", "update", "read", "delete"]
}
# Allow reading auth methods
path "sys/auth" {
capabilities = ["read", "list"]
}
# Allow reading mounts (including auth mounts)
path "sys/mounts" {
capabilities = ["read", "list"]
}
path "sys/mounts/*" {
capabilities = ["read", "list"]
}
# Allow token creation (required by Vault provider)
path "auth/token/create" {
capabilities = ["create", "update"]
}
# Allow token lookup (for self)
path "auth/token/lookup-self" {
capabilities = ["read"]
}
# Allow token renewal (for self)
path "auth/token/renew-self" {
capabilities = ["update"]
}Apply the policy:
vault policy write terraform-admin /tmp/terraform-admin-policy.hclvariables.tf:
variable "vault_jwt_role" {
description = "Vault JWT role for authentication"
type = string
default = "terraform-github-actions"
}
variable "vault_jwt_token" {
description = "JWT token from GitHub OIDC"
type = string
sensitive = true
}provider.tf:
provider "vault" {
address = "https://vault.k3s.internal.mydomain.fr/"
auth_login_jwt {
role = var.vault_jwt_role
jwt = var.vault_jwt_token
}
}name: Terraform Deployment
on:
workflow_dispatch:
inputs:
action:
type: choice
options: [plan, apply]
pull_request:
paths: ['terraform/**']
push:
branches: [main, 'feature/**']
paths: ['terraform/**']
permissions:
id-token: write # Required for OIDC token
contents: read
pull-requests: write
jobs:
terraform:
runs-on: arc-runner-set
steps:
- uses: actions/checkout@v5
- name: Get GitHub OIDC Token
id: oidc
run: |
OIDC_TOKEN=$(curl -sS -H "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \
"$ACTIONS_ID_TOKEN_REQUEST_URL&audience=https://vault.k3s.internal.mydomain.fr" | jq -r '.value')
echo "::add-mask::$OIDC_TOKEN"
echo "token=$OIDC_TOKEN" >> $GITHUB_OUTPUT
- name: Terraform Plan
if: github.event_name == 'pull_request'
env:
TF_VAR_vault_jwt_token: ${{ steps.oidc.outputs.token }}
TF_VAR_vault_jwt_role: terraform-github-actions-pr
run: terraform plan
- name: Terraform Apply
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
env:
TF_VAR_vault_jwt_token: ${{ steps.oidc.outputs.token }}
TF_VAR_vault_jwt_role: terraform-github-actions
run: terraform apply -auto-approve| Aspect | Before (Token) | After (OIDC) |
|---|---|---|
| Authentication | Long-lived static token | Short-lived OIDC token (1h) |
| Storage | In terraform.tfvars or GitHub secrets | Generated on-demand |
| Permissions | Root access to all paths | Scoped to necessary paths only |
| Rotation | Manual | Automatic per workflow run |
| Audit Trail | Token ID | GitHub actor + repository |
| Compromise Risk | High (full access if leaked) | Low (limited scope, short TTL) |
| Environment Separation | Same token everywhere | Different roles per environment |
- No Stored Secrets: OIDC tokens are generated dynamically - nothing to leak
- Principle of Least Privilege: Policy grants only what Terraform needs
- Time-Limited Access: 1-hour TTL means compromised tokens expire quickly
- Audit Traceability: Vault logs show which GitHub user/workflow accessed what
- Bound Claims: Tokens only valid for specific repository and branches