HashiCorp Vault · Plugin Architecture · Trade-off Analysis

Two Paths to Credential Rotation

Vault's plugin SDK supports multiple approaches to extending its secrets engine surface. For platforms without a native engine, we built and compared two architectures — not to declare a winner, but to make the trade-offs visible so teams can choose deliberately.

A — Native Plugin B — Generic REST Engine
Why this comparison exists. Vault ships native secrets engines for AWS IAM, Azure AD, GCP, PostgreSQL, MySQL, MongoDB, PKI, SSH, and more. For any platform that doesn't have one, teams have two architectural choices: write a native plugin per platform, or deploy a single generic REST secrets engine that delegates to a provider-agnostic rotation service. Both are valid. This page makes the trade-offs concrete with a real example: the Auth0 platform, where we built both approaches side by side.
Approach A
vault-auth0-engine
A native Vault secrets plugin that calls the Auth0 Management API directly. One plugin per platform. Each plugin owns its full rotation logic.
Approach B
vault-rest-engine + cred-rotation-api
A generic REST secrets plugin that delegates to a platform-agnostic rotation service. All platforms share one plugin; each gets a thin adapter.
A — Native Plugin Architecture
flowchart TB
    app["Consumer App"]
    subgraph vault["HashiCorp Vault"]
        engine_a0["vault-auth0-engine\n(Auth0 plugin)"]
        engine_sq["vault-sq-engine\n(SonarQube plugin)"]
        engine_dd["vault-dd-engine\n(Datadog plugin)"]
        transit["Transit Engine"]
    end
    auth0["Auth0 Mgmt API"]
    sonar["SonarQube Web API"]
    datadog["Datadog API"]

    app -- "TLS" --> engine_a0
    app -- "TLS" --> engine_sq
    app -- "TLS" --> engine_dd
    engine_a0 -- "TLS 1.3" --> auth0
    engine_sq -- "TLS 1.3" --> sonar
    engine_dd -- "TLS 1.3" --> datadog
    engine_a0 & engine_sq & engine_dd -- "Transit\nencrypt/decrypt" --> transit
One plugin binary per platform. Adding a new platform = writing and registering a new Vault plugin.
B — Generic REST Engine Architecture
flowchart TB
    app["Consumer App"]
    subgraph vault["HashiCorp Vault"]
        engine["vault-rest-engine\n(one plugin, all platforms)"]
        transit["Transit Engine"]
        pki["PKI Engine\nInternal CA"]
    end
    subgraph broker["cred-rotation-api · mTLS"]
        a0["Auth0\nAdapter"]
        sq["SonarQube\nAdapter"]
        dd["Datadog\nAdapter"]
    end
    auth0["Auth0 Mgmt API"]
    sonar["SonarQube Web API"]
    datadog["Datadog API"]

    app -- "TLS" --> engine
    engine -- "mTLS\nclient cert" --> broker
    a0 -- "TLS 1.3" --> auth0
    sq -- "TLS 1.3" --> sonar
    dd -- "TLS 1.3" --> datadog
    engine & broker -- "Transit\nencrypt/decrypt" --> transit
    pki -- "issues cert\nfor plugin" --> engine
One plugin binary for all platforms. Adding a new platform = writing a new adapter in the rotation service.

Request Path — Step by Step

A — vault-auth0-engine (4 hops)
1
Consumer → Vault (TLS)
vault read auth0/creds/my-role
2
Plugin → Auth0 Management API (TLS 1.3)
POST /api/v2/clients/{id}/rotate-secret — direct API call
3
Plugin → Transit (encrypt new secret)
Plaintext → vault:v1: ciphertext
4
Plugin → Transit (decrypt on read)
vault:v1: ciphertext → plaintext in memory → consumer
B — vault-rest-engine + cred-rotation-api (6 hops)
1
Consumer → Vault (TLS)
vault read generic/creds/my-role
2
Plugin → cred-rotation-api (mTLS)
POST /v1/credentials/rotate — PKI client cert validates both sides
3
Adapter → Auth0 Management API (TLS 1.3)
POST /api/v2/clients/{id}/rotate-secret
4
Adapter → Transit (encrypt new secret)
Plaintext → vault:v1: ciphertext — before it leaves the API
5
API → Plugin (mTLS, ciphertext only)
Plugin never receives plaintext credential
6
Plugin → Transit (decrypt on read)
vault:v1: ciphertext → plaintext in plugin memory → consumer

What You Actually Write

To add a new platform (SonarQube in this example), here is what changes under each approach:

Approach A — New Vault Plugin Go
// Must write an entire new Vault plugin
package sonarqubeplugin

// ─ New plugin binary to register with Vault
func Factory(ctx context.Context,
    conf *backend.BackendConfig) (logical.Backend, error) {
    b := &backend{}
    b.Backend = &framework.Backend{
        Paths:       b.paths(),
        BackendType: logical.TypeLogical,
    }
    return b, b.Setup(ctx, conf)
}

// ─ Implement config, roles, creds paths
func (b *backend) paths() []*framework.Path {
    return []*framework.Path{
        b.pathConfig(),
        b.pathRoles(),
        b.pathCredsRead(), // rotation logic here
    }
}

// ─ Write rotation logic (SonarQube-specific)
// ─ Write revocation logic
// ─ Register SHA256-pinned binary with Vault
// ─ Write tests, handle Vault SDK lifecycle
Approach B — New Adapter Only Go
// Only write a new adapter — plugin unchanged
package sonarqube

// ─ Implement the Adapter interface (3 methods)
type Adapter struct {
    baseURL    string
    adminToken string
    httpClient *http.Client
}

func (a *Adapter) Rotate(
    ctx context.Context,
    req adapter.RotateRequest,
) (adapter.Result, error) {
    // call SonarQube API, return encrypted result
}

func (a *Adapter) Revoke(
    ctx context.Context,
    req adapter.RevokeRequest,
) error { /* revoke token */ }

func (a *Adapter) Status(
    ctx context.Context,
    credentialID string,
) (adapter.CredentialStatus, error) { /* check */ }

func (a *Adapter) Name() string { return "sonarqube" }

// ─ Register in main.go: reg.Register(sonarqube.New(cfg))
// ─ No Vault plugin changes. No binary re-registration.

Trade-off Matrix

Dimension A — Native Plugin B — Generic REST Engine
Hop count (read path) 4 hops — faster, fewer network roundtrips 6 hops — 2 extra (plugin → API → plugin)
Adding a new platform New Vault plugin binary — write, test, register SHA256 pin, restart Vault (or hot-reload) New adapter (~200 lines) — no plugin changes, no Vault restart, no binary re-registration
Vault plugin count 1 plugin per platform — grows with the number of providers 1 plugin for all platforms — single binary to maintain and register
Vault plugin upgrade cycle Restart or hot-reload Vault per plugin for every update Restart only cred-rotation-api — Vault itself untouched for adapter changes
Plaintext exposure surface Plaintext in plugin memory — standard Vault pattern, well-understood Plaintext in API memory only — plugin only ever sees vault:v1: ciphertext; stronger isolation
mTLS between components No extra mTLS layer — fewer TLS connections mTLS between plugin and API — extra TLS handshake; provides mutual authentication
Independent scaling Plugin runs inside Vault process — cannot scale independently cred-rotation-api scales independently — Kubernetes HPA, separate resource limits
Independent deployment Plugin tied to Vault lifecycle — deploy Vault to update a provider Deploy adapter changes separately — zero downtime for Vault consumers
Language / framework freedom Must use Vault Plugin SDK (Go) — all platforms share the same tech stack cred-rotation-api language-agnostic — adapters could move to other languages behind the interface
Operational complexity Simpler overall — one fewer service to run, monitor, and secure One extra service — cred-rotation-api needs its own TLS certs, health checks, observability
Auth isolation (SPIFFE) Plugin inherits Vault's identity — harder to attest separately API gets its own SPIFFE SVID — workload identity is independently attested by SPIRE
Best fit 1–3 platforms, stable provider set, performance-critical paths 4+ platforms, frequent provider additions, distributed teams, regulated environments requiring isolation

Security Difference: Plaintext Isolation

🔑
Approach A:

The native plugin receives the plaintext credential from the SaaS API response and holds it in the Vault plugin process memory. This is the standard Vault dynamic secrets pattern — Vault core manages the credential lifecycle. Security perimeter: Vault process.

🔐
Approach B — stronger isolation:

The plaintext credential never reaches the Vault plugin. The rotation API encrypts it with Transit immediately after the SaaS provider responds, and only the vault:v1: ciphertext travels over mTLS back to the plugin. Even a compromised mTLS key yields only ciphertext, not the credential. Security perimeter: cred-rotation-api process only.

Both approaches use Transit envelope encryption (AES-256-GCM96) and TLS 1.3 on all outbound connections. The difference is where the plaintext window exists: in the Vault plugin process (Approach A) or exclusively in the rotation API process (Approach B). In regulated environments where the Vault process boundary is the audit boundary, Approach B's extra isolation layer has real compliance value.

Which Approach to Choose

Choose Approach A (Native Plugin) when…
You have 1–3 platforms and the set is stable
Performance is the primary constraint (fewer hops)
Your team has strong Vault SDK / Go expertise
You want fewer moving parts and simpler operations
Vault's native plugin isolation is sufficient for your compliance boundary
The target platform's API is well-understood and unlikely to change shape
Choose Approach B (Generic REST Engine) when…
You have 4+ platforms or expect to add more over time
Different teams own different platform adapters
You need zero-downtime adapter deployments without Vault restarts
Regulated environments require explicit process-level isolation for plaintext credentials
You want SPIFFE workload identity for the rotation service independently of Vault
The rotation service needs to scale, have its own SLOs, or be owned by a security team

The Verdict

No universal winner — context decides.
Approach A is simpler and faster for small, stable platform sets. Approach B trades operational simplicity for organizational flexibility and stronger credential isolation. The right answer depends on how many platforms you need to support, who owns them, and what your compliance requirements are. This project implements both so teams can evaluate the pattern against their own constraints — not because one is objectively better.
Both share the same security foundation Transit AES-256-GCM96, TLS 1.3 on all outbound calls, Vault audit log for every credential lifecycle event, fail-closed revocation.
The key differentiator is the plaintext boundary Approach A: Vault plugin process. Approach B: cred-rotation-api process. Neither is inherently wrong — the choice depends on your audit and compliance model.