# Replacing Root Tokens with SSO: HashiCorp Vault + Authentik OIDC

- Canonical: https://matheja.me/2025/11/20/vault-oidc-authentik-sso.html
- Date: 2025-11-20
- Author: Ben Matheja
- Tags: homelab, vault, security, oidc, authentik
- Site: Ben Matheja (https://matheja.me), llms.txt: https://matheja.me/llms.txt

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:

```bash
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

```hcl
# terraform/modules/vault/auth-methods.tf
resource "vault_jwt_auth_backend" "authentik" {
  path               = "oidc"
  type               = "oidc"
  oidc_discovery_url = "https://auth.example.com/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

```hcl
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.example.internal/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:

```hcl
# 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):

```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:

```hcl
# 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.example.internal/ui/vault/auth/oidc/oidc/callback"
    },
    {
      matching_mode = "strict"
      url           = "https://vault.example.internal/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

```bash
$ 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.example.internal` → Select "OIDC" method → Redirects to Authentik

### Ansible Integration

```yaml
# 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.example.internal'
    ).password }}"
```

For automation, use service accounts with AppRole auth:

```hcl
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.
