If you’re using Vault’s root token for daily operations, you’re doing it wrong. I was too. Then I accidentally committed my .vault-token file to Gitlab.

The Problem

My original Vault workflow:

export VAULT_TOKEN="hvs.CAESIF..."
vault kv get secret/my-app

Why this is terrible:

  • ❌ Root token never expires (unlimited attack window)
  • ❌ No audit trail (all operations appear as “root”)
  • ❌ Single point of failure (anyone with this token has god-mode)
  • ❌ No MFA (compromised laptop = compromised infrastructure)
  • ❌ Can’t revoke selectively (revoking root token locks everyone out)

The wake-up call: I accidentally committed .vault-token to Gitlab. Panic-revoking the root token meant:

  1. Regenerating via Terraform
  2. Updating every automation script
  3. Breaking every service that depended on Vault (all of them)

The Solution

Replace root tokens with federated authentication. Users log in once through Authentik SSO, and Vault grants permissions based on group membership.

Architecture

User/Script
    │
    ▼
HashiCorp Vault (OIDC Client)
    │
    │ Redirect
    ▼
Authentik (OIDC Provider)
    │
    │ Groups: vault-admins, vault-readonly
    ▼
User Identity

Why Authentik? I already had it running for GitLab, Grafana, and other services. Adding Vault to the same identity provider gives:

  • ✅ Single source of truth for identities
  • ✅ Centralized MFA enforcement (TOTP, WebAuthn)
  • ✅ Group-based RBAC (manage access in one place)
  • ✅ Audit trail (who accessed what, when, from where)
  • ✅ Token expiration (force re-authentication every 8 hours)

Implementation with Terraform

1. Enable OIDC Auth Method

# terraform/modules/vault/auth-methods.tf
resource "vault_jwt_auth_backend" "authentik" {
  path               = "oidc"
  type               = "oidc"
  oidc_discovery_url = "https://auth.mittbachweg.de/application/o/vault/"
  oidc_client_id     = var.authentik_client_id
  oidc_client_secret = var.authentik_client_secret
  default_role       = "default"
  
  tune {
    default_lease_ttl = "8h"
    max_lease_ttl     = "24h"
  }
}

Key parameters:

  • oidc_discovery_url: Authentik’s OIDC discovery endpoint
  • default_lease_ttl: Tokens expire after 8 hours (force re-authentication)

2. Configure OIDC Roles

resource "vault_jwt_auth_backend_role" "default" {
  backend         = vault_jwt_auth_backend.authentik.path
  role_name       = "default"
  bound_audiences = [var.authentik_client_id]
  user_claim      = "sub"
  role_type       = "oidc"
  
  token_policies = [
    "default",
    "read-own-token"
  ]
  
  groups_claim = "groups"
  allowed_redirect_uris = [
    "http://localhost:8250/oidc/callback",
    "https://vault.one.mittbachweg.de/ui/vault/auth/oidc/oidc/callback"
  ]
  
  claim_mappings = {
    preferred_username = "username"
    email              = "email"
  }
}

Role breakdown:

  • bound_audiences: Only accept tokens for this Vault instance (prevents token replay)
  • groups_claim: Extract Authentik group memberships from JWT
  • allowed_redirect_uris: Whitelist for OAuth2 redirect (CLI and Web UI)

3. Group-Based Policies

Map Authentik groups to Vault policies:

# Admin group gets full access
resource "vault_identity_group" "vault_admins" {
  name     = "vault-admins"
  type     = "external"
  policies = [
    "admin",
    "create-tokens",
    "manage-auth"
  ]
}

# Readonly group gets limited access
resource "vault_identity_group" "vault_readonly" {
  name     = "vault-readonly"
  type     = "external"
  policies = [
    "read-secrets",
    "read-own-token"
  ]
}

# Bind to Authentik groups
resource "vault_identity_group_alias" "vault_admins" {
  name           = "vault-admins"
  mount_accessor = vault_jwt_auth_backend.authentik.accessor
  canonical_id   = vault_identity_group.vault_admins.id
}

Policy example (admin.hcl):

# Full access to secrets
path "secret/*" {
  capabilities = ["create", "read", "update", "delete", "list"]
}

# Manage auth methods
path "auth/*" {
  capabilities = ["create", "read", "update", "delete", "list", "sudo"]
}

Authentik Configuration

Create an OAuth2/OpenID provider in Authentik:

# OAuth2 Provider for Vault
resource "authentik_provider_oauth2" "vault" {
  name               = "Vault OIDC Provider"
  client_id          = "vault"
  client_secret      = random_password.vault_oauth_secret.result
  authorization_flow = data.authentik_flow.default-provider-authorization-implicit-consent.id
  invalidation_flow  = data.authentik_flow.default-invalidation-flow.id

  # Redirect URIs for Vault UI, API, and CLI
  allowed_redirect_uris = [
    {
      matching_mode = "strict"
      url           = "https://vault.one.mittbachweg.de/ui/vault/auth/oidc/oidc/callback"
    },
    {
      matching_mode = "strict"
      url           = "https://vault.one.mittbachweg.de/oidc/callback"
    },
    {
      matching_mode = "strict"
      url           = "http://localhost:8250/oidc/callback"
    },
  ]

  # Property mappings for OAuth scopes
  property_mappings = [
    data.authentik_property_mapping_provider_scope.scope-email.id,
    data.authentik_property_mapping_provider_scope.scope-profile.id,
    data.authentik_property_mapping_provider_scope.scope-openid.id,
  ]

  # Additional configuration for proper OIDC flow
  signing_key = data.authentik_certificate_key_pair.default.id
}

# Vault OIDC Application
resource "authentik_application" "vault" {
  name               = "HashiCorp Vault"
  slug               = "vault"
  protocol_provider  = authentik_provider_oauth2.vault.id
  meta_description   = "OIDC authentication for HashiCorp Vault"
  meta_publisher     = "Mittbachweg Infrastructure"
  policy_engine_mode = "any"
}

Usage

CLI Login

$ vault login -method=oidc

# Browser opens for Authentik login
# After success:
Success! You are now authenticated.

$ vault token lookup
Key                 Value
---                 -----
entity_id           8a7b9c1d-2e3f-4a5b-6c7d-8e9f0a1b2c3d
expire_time         2024-02-15T19:20:00Z
policies            [default read-secrets]

Web UI Login

Navigate to https://vault.one.mittbachweg.de → Select “OIDC” method → Redirects to Authentik

Ansible Integration

# playbooks/vault_lookup.yml
- name: Fetch secret from Vault
  set_fact:
    db_password: "{{ lookup('community.hashi_vault.hashi_vault_read',
      'secret=ansible/data/database',
      auth_method='oidc',
      url='https://vault.one.mittbachweg.de'
    ).password }}"

For automation, use service accounts with AppRole auth:

resource "vault_auth_backend" "approle" {
  type = "approle"
}

resource "vault_approle_auth_backend_role" "ansible" {
  backend        = vault_auth_backend.approle.path
  role_name      = "ansible"
  token_policies = ["read-secrets"]
  token_ttl      = 3600
}

Lessons Learned

What worked:

  • ✅ OIDC login eliminated root token dependency
  • ✅ Group-based RBAC simplified permission management
  • ✅ MFA enforcement via Authentik blocked unauthorized access
  • ✅ Token expiration forced regular re-authentication

What didn’t:

  • ❌ Initial 8-hour token TTL was too short for long-running tasks (extended to 24h max)
  • ❌ Forgot to configure service accounts for CI/CD (added AppRole auth)
  • ❌ Debugging OIDC callback issues required checking Vault AND Authentik logs

The security improvement is worth the complexity. No more root tokens in plaintext files.