Implement a custom credential backend¶
credentials ships two backends out of the box: a stub that returns
ErrCredentialUnsupported (the regulated default) and a go-keyring wrapper in
credentials/keychain (desktop/laptop). Tool authors who
need a different secret store — Hashicorp Vault, AWS Secrets Manager / SSM Parameter
Store, GCP Secret Manager, 1Password Connect, a bespoke corporate store — implement
the Backend
interface and register it at process startup.
This guide walks through the full shape of a custom backend using Vault's KV v2 engine as the worked example. The same pattern applies to any store.
This is by design a tool-author concern
The package deliberately ships no bundled vendor adapters — Vault/SSM/etc.
pull heavy, deployment-specific SDKs that do not belong in a framework-free
core. Backend is the extension point; you own the adapter for your store.
When to build one¶
Consider a custom backend when:
- Your organisation mandates a specific secret store (Vault, cloud KMS, internal HSM-backed service) for the credentials your tool handles.
- You want centralised rotation, audit logs, or policy-based access the OS keychain cannot provide.
- You run many instances of your tool across a fleet and need a single source of truth.
Stick with the built-ins when you are a single-user desktop tool (OS keychain is fine) or only need env-var references (already the default mode).
The Backend contract¶
type Backend interface {
Store(ctx context.Context, service, account, secret string) error
Retrieve(ctx context.Context, service, account string) (string, error)
Delete(ctx context.Context, service, account string) error
Available() bool
}
Required semantics for each method:
| Method | Must do | Must not do |
|---|---|---|
Store |
Write a secret under service/account. Overwrite any existing entry. Return ctx.Err() on cancellation before commit. |
Log secret. Embed secret in error messages. |
Retrieve |
Return the stored value when present. Return ErrCredentialNotFound when the backend is reachable but the entry does not exist. Wrap other failures. |
Signal "missing" by returning an empty string with nil error — resolvers rely on the sentinel to fall through cleanly. |
Delete |
Remove the entry if present. Return nil when it is missing (idempotent). |
Surface a "not found" error — it confuses callers that re-run setup. |
Available |
Report whether the backend could satisfy calls right now, from a cheap static check (initialised, token present, pool ready). | Perform I/O — that is what Probe is for. |
Return-value conventions:
ErrCredentialNotFound— backend healthy, entry absent. Callers fall through to the next resolution step.ErrCredentialUnsupported— the stub backend. Callers fall through.- Any other error — a real failure (auth, network, permission), surfaced by direct
Store/Retrieve/Deletecallers such as a setup flow.
Worked example: Hashicorp Vault (KV v2)¶
Uses the official github.com/hashicorp/vault/api client, KV v2 at mount secret/,
token auth. The structure adapts to approle, Kubernetes, or cloud IAM auth with only
the constructor changing.
1. Skeleton¶
Create yourtool/credentials/vault/backend.go:
// Package vault provides a credentials.Backend backed by Hashicorp
// Vault's KV v2 secrets engine.
package vault
import (
"context"
stderrors "errors"
"fmt"
"github.com/cockroachdb/errors"
vaultapi "github.com/hashicorp/vault/api"
"gitlab.com/phpboyscout/go/credentials"
)
// Backend writes secrets under <mount>/data/<prefix>/<service>/<account>.
// KV v2 places each secret's payload under a "data" key; we stash the
// single-value secret as {"value": "<secret>"} to match the simple
// Store/Retrieve/Delete contract.
type Backend struct {
client *vaultapi.Client
mount string // e.g. "secret" (no leading/trailing slash)
prefix string // e.g. "mytool" — namespaces the tool's entries
}
// Config configures the Vault backend.
type Config struct {
Address string // VAULT_ADDR; e.g. "https://vault.corp:8200"
Token string // VAULT_TOKEN; the app's token
Mount string // KV v2 mount path; defaults to "secret"
Prefix string // namespace inside the mount; defaults to "creds"
}
// New builds a Backend from Config. Vault round-trips happen lazily on
// first call.
func New(cfg Config) (*Backend, error) {
vc := vaultapi.DefaultConfig()
if err := vc.Error; err != nil {
return nil, errors.Wrap(err, "default vault config")
}
if cfg.Address != "" {
vc.Address = cfg.Address
}
client, err := vaultapi.NewClient(vc)
if err != nil {
return nil, errors.Wrap(err, "vault.NewClient")
}
if cfg.Token != "" {
client.SetToken(cfg.Token)
}
mount := cfg.Mount
if mount == "" {
mount = "secret"
}
prefix := cfg.Prefix
if prefix == "" {
prefix = "creds"
}
return &Backend{client: client, mount: mount, prefix: prefix}, nil
}
// path returns the KV v2 logical path for a secret.
func (b *Backend) path(service, account string) string {
return fmt.Sprintf("%s/data/%s/%s/%s", b.mount, b.prefix, service, account)
}
2. Implement the methods¶
// Store writes a KV v2 entry with payload {"value": secret}.
func (b *Backend) Store(ctx context.Context, service, account, secret string) error {
_, err := b.client.Logical().WriteWithContext(ctx, b.path(service, account), map[string]any{
"data": map[string]any{"value": secret},
})
if err != nil {
// Never include `secret` in the wrapped error — Vault sometimes
// echoes the payload in diagnostics.
return errors.Wrapf(err, "vault.Write %s/%s", service, account)
}
return nil
}
// Retrieve reads a KV v2 entry. Returns ErrCredentialNotFound when Vault
// is reachable but the path has no secret.
func (b *Backend) Retrieve(ctx context.Context, service, account string) (string, error) {
sec, err := b.client.Logical().ReadWithContext(ctx, b.path(service, account))
if err != nil {
return "", errors.Wrapf(err, "vault.Read %s/%s", service, account)
}
if sec == nil || sec.Data == nil {
return "", credentials.ErrCredentialNotFound
}
data, ok := sec.Data["data"].(map[string]any)
if !ok || data == nil {
return "", credentials.ErrCredentialNotFound
}
v, ok := data["value"].(string)
if !ok {
return "", credentials.ErrCredentialNotFound
}
return v, nil
}
// Delete removes the current version. Idempotent: Vault reports no error
// whether the entry was present or already absent.
func (b *Backend) Delete(ctx context.Context, service, account string) error {
_, err := b.client.Logical().DeleteWithContext(ctx, b.path(service, account))
if err != nil {
if stderrors.Is(err, vaultapi.ErrSecretNotFound) {
return nil
}
return errors.Wrapf(err, "vault.Delete %s/%s", service, account)
}
return nil
}
// Available reports whether the backend is ready. Cheap check: a token
// is set. For a true liveness signal use credentials.Probe.
func (b *Backend) Available() bool {
return b.client != nil && b.client.Token() != ""
}
3. Register at startup¶
Register the backend before the first credential call. Two patterns:
// yourtool/cmd/yourtool/vault.go — delete this file to fall back to the stub.
package main
import _ "yourtool/internal/vaultinit"
// yourtool/internal/vaultinit/vaultinit.go
package vaultinit
import (
"log"
"os"
"gitlab.com/phpboyscout/go/credentials"
"yourtool/credentials/vault"
)
//nolint:gochecknoinits // side-effect registration is the whole point
func init() {
b, err := vault.New(vault.Config{
Address: os.Getenv("VAULT_ADDR"),
Token: os.Getenv("VAULT_TOKEN"),
Prefix: "yourtool",
})
if err != nil {
log.Fatalf("vault backend: %v", err)
}
credentials.RegisterBackend(b)
}
The blank-import form composes with the keychain opt-out file — both register their
backend via init(), and the later registration wins. Use the explicit form when
construction is config-driven (a flag selecting Vault vs OS keychain).
4. Test it¶
For unit tests of code that depends on a backend (resolvers, setup flows), do not
stand up real Vault — use the in-process credtest.MemoryBackend:
import (
"gitlab.com/phpboyscout/go/credentials"
"gitlab.com/phpboyscout/go/credentials/credtest"
)
func TestResolverReadsFromBackend(t *testing.T) {
credtest.Install(t) // registers a fresh MemoryBackend, restores the stub on cleanup
require.NoError(t, credentials.Store(t.Context(), "mytool", "github.auth", "ghp_test"))
// ... drive your resolver and assert it picks up the stored value.
}
Round-trip integration tests for the real Vault implementation belong behind an
env-var gate, e.g. INT_TEST_VAULT=1 go test ./yourtool/credentials/vault/....
Composing backends¶
To "try Vault first, fall back to OS keychain", write a wrapping Backend — the
registry holds one backend at a time:
type ChainedBackend struct {
backends []credentials.Backend
}
func (c *ChainedBackend) Retrieve(ctx context.Context, service, account string) (string, error) {
var lastErr error
for _, b := range c.backends {
v, err := b.Retrieve(ctx, service, account)
if err == nil {
return v, nil
}
lastErr = err // ErrCredentialNotFound or unavailable — try the next
}
return "", lastErr
}
// Store typically writes to the first Available() backend only.
credentials.RegisterBackend(&ChainedBackend{
backends: []credentials.Backend{
mustBuildVaultBackend(),
keychain.Backend{}, // from credentials/keychain
},
})
Current limitations¶
- No built-in retry. Transient network errors surface as-is; wrap your client with retry logic before registering.
- No built-in caching. Each
Retrievehits the backend; caching is your concern — mind token rotation. - Single active backend. Composition is via a wrapping
Backend, not a registration chain. - Thin error taxonomy.
ErrCredentialNotFoundandErrCredentialUnsupportedare the only sentinels; distinguish auth vs permission failures viaerrors.Ason your own error types.
Related¶
- Enable OS-keychain storage — the canonical
Backendimplementation, useful as a reference. - Test credential flows —
credtestfor unit tests. - The keychain opt-out — why vendor adapters are not bundled.