In modern microservices architectures, machine identity is just as critical as human identity. When services communicate, they often need to prove not just who they are, but what they are—their environment, business unit, or SPIFFE ID.
HashiCorp Vault is excellent for this. Its Identity Secrets Engine can issue OIDC tokens that serve as verifiable credentials for your workloads. However, getting those tokens to contain rich, custom metadata requires connecting a few specific dots: AppRole, Identity Entities, and OIDC Templates.
In this article, I'll walk through how to implement a robust machine identity pipeline using Terraform, where an application authenticates via AppRole and receives an OIDC token enriched with custom metadata.
The Goal
We want an application (e.g., a "ChatBot") to:
- Authenticate to Vault using the AppRole method.
- Request an OIDC token.
- Receive a token containing custom claims like
spiffe_id,business_unit, andenvironment.
Step 1: Define the Identity Entity
First, we define the "who". In Vault, an Entity represents a unique identity. This is where we store the metadata we want to eventually see in our token.
# identities.tf
resource "vault_identity_entity" "application" {
for_each = local.application_identities_map
name = each.key
# This metadata is what we'll inject into the token later
metadata = {
environment = each.value.identity.environment
business_unit = each.value.identity.business_unit
spiffe_id = "spiffe://vault/application/${each.value.identity.environment}/${each.value.identity.business_unit}/${each.value.identity.name}"
}
}
Step 2: Configure AppRole Authentication
Next, we configure the AppRole auth backend. This is the standard way for machines to authenticate.
# approle.tf
resource "vault_approle_auth_backend_role" "applications" {
for_each = local.application_identities_map
backend = vault_auth_backend.approle.path
role_name = each.key
token_ttl = 3600
bind_secret_id = true
}
The "Secret Sauce": Binding AppRole to the Entity
This is the most critical step. By default, when you log in with AppRole, Vault creates a generic entity for that role. To use our custom metadata defined in Step 1, we must explicitly bind the AppRole to our specific Entity using an Entity Alias.
Important Gotcha: When creating an alias for AppRole, the name of the alias must be the Role ID, not the Role Name.
# approle.tf
resource "vault_identity_entity_alias" "approle_applications" {
for_each = local.application_identities_map
# CRITICAL: Use role_id, not role_name
name = vault_approle_auth_backend_role.applications[each.key].role_id
mount_accessor = vault_auth_backend.approle.accessor
canonical_id = vault_identity_entity.application[each.key].id
}
We also need to ensure the AppRole inherits the entity's properties. We can enforce this via a generic endpoint configuration:
resource "vault_generic_endpoint" "approle_entity_inherit" {
for_each = local.application_identities_map
depends_on = [vault_approle_auth_backend_role.applications]
path = "auth/approle/role/${each.key}"
data_json = jsonencode({
entity_alias_sole_inherit = true
})
}
Step 3: Configure the OIDC Template
Finally, we configure the OIDC provider and role. The template field is where the magic happens. We use Vault's template syntax to pull values dynamically from the authenticated entity's metadata.
# identity_tokens.tf
resource "vault_identity_oidc_role" "application_identity" {
name = "application_identity"
key = vault_identity_oidc_key.application_identity.name
client_id = "spiffe://vault.darkedges.au/gateway"
ttl = 86400
# The Template: Injecting metadata into the JSON payload
template = <<EOT
{
"azp": {{identity.entity.metadata.spiffe_id}},
"nbf": {{time.now}},
"groups": {{identity.entity.groups.names}},
"appinfo": {
"business_unit": {{identity.entity.metadata.business_unit}},
"environment": {{identity.entity.metadata.environment}}
}
}
EOT
}
Step 4: Testing the Flow
Now, let's see it in action. We'll use PowerShell to simulate the application workflow.
1. Get Credentials & Login
First, we retrieve the Role ID and Secret ID, then authenticate to get a Vault token.
# Get Role ID and Secret ID
$ROLE_ID = docker-compose exec vault vault read -field=role_id auth/approle/role/ChatBot/role-id
$SECRET_ID = docker-compose exec vault vault write -f -field=secret_id auth/approle/role/ChatBot/secret-id
# Login
$APPTOKEN = docker-compose exec vault vault write -field=token auth/approle/login role_id="$ROLE_ID" secret_id="$SECRET_ID"
2. Request the OIDC Token
Using the Vault token we just got, we request the OIDC token.
$OIDC_TOKEN = docker-compose exec -e VAULT_TOKEN="$APPTOKEN" vault vault read -field=token identity/oidc/token/application_identity
3. The Result
When we decode the JWT, we see our rich metadata is successfully populated!
{
"appinfo": {
"business_unit": "engineering",
"environment": "production"
},
"aud": "spiffe://vault.darkedges.au/gateway",
"azp": "spiffe://vault/application/production/engineering/ChatBot",
"exp": 1764848993,
"groups": [
"ChatBot group"
],
"iat": 1764762593,
"iss": "https://vault.darkedges.au/v1/identity/oidc",
"sub": "ea0e0006-d4f5-cbde-3cb6-c013d4dba5f2"
}
Conclusion
By combining Identity Entities, AppRole Aliases, and OIDC Templates, we've turned Vault into a powerful identity provider for our internal services. This setup allows downstream systems (like API gateways or service meshes) to make authorization decisions based on trusted, verifiable metadata like business_unit or environment, rather than just a raw IP address or generic token.
a complete example is available at https://github.com/darkedges/spiffe-vault-terraform
Top comments (0)