Implement a custom credential backend¶
pkg/credentials ships with two backends out of the box: a stub that returns ErrCredentialUnsupported (regulated-default) and a go-keyring wrapper in pkg/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 custom corporate store โ implement the credentials.Backend interface and register their implementation at process startup.
This guide walks through the full shape of a custom backend using Hashicorp Vault's KV v2 secrets engine as the worked example. The same pattern applies to any other store.
When to build one¶
Consider a custom backend when:
- Your organisation mandates a specific secret store (Vault, cloud-native KMS, internal HSM-backed service) for AI credentials or VCS tokens.
- You want centralised rotation, audit logs, or policy-based access that the OS keychain cannot provide.
- You're running many instances of a GTB-built tool across a fleet and need a single source of truth.
Stick with the built-in backends when:
- You're a single-user desktop tool (OS keychain is fine).
- You only need env-var references (already the default storage 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. |
Distinguish "missing" by returning an empty string with nil err โ resolvers rely on the sentinel to fall through cleanly. |
Delete |
Remove the entry if present. Return nil when the entry 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, based on a cheap static check (initialised, token present, connection pooled). | Perform I/O โ that is what credentials.Probe(ctx) is for. |
Return-value conventions:
ErrCredentialNotFoundโ backend healthy, entry absent. Callers fall through to the next resolution step.ErrCredentialUnsupportedโ backend is the stub. Callers fall through.- Any other error โ real failure (auth, network, permission). Callers still fall through for VCS/chat resolvers but the error may be surfaced by direct
credentials.Store/Retrieve/Deletecallers such as the setup wizard.
Worked example: Hashicorp Vault (KV v2)¶
This implementation uses the official github.com/hashicorp/vault/api client, KV v2 at mount path secret/, token-based auth. The same 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-tool-base/pkg/credentials"
)
// Backend writes secrets under <mount>/data/<prefix>/<service>/<account>.
// The KV v2 engine 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 "gtb"
}
// New builds a Backend from Config. Returns an error if the Vault
// client cannot be constructed; 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 = "gtb"
}
return &Backend{client: client, mount: mount, prefix: prefix}, nil
}
// path returns the KV v2 logical path for a secret. KV v2 maps
// <mount>/<key> to <mount>/data/<key> for reads/writes; using the
// high-level KVv2 API abstracts this, but we build paths manually to
// keep the dependency minimal.
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 at <mount>/data/<prefix>/<service>/<account>
// 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 (either never written
// or deleted with purge).
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)
}
// KV v2: nil response or nil .Data means the entry doesn't exist;
// a deleted-but-not-destroyed entry returns non-nil Data with a
// "metadata" key but empty "data" โ treat both as not-found.
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 of the KV v2 entry. Idempotent:
// Vault returns 204 for both "was present, now gone" and "was already
// absent" โ the client reports no error in either case.
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 to serve requests.
// The check is cheap: we verify the Vault client was constructed with
// a token. For a true liveness signal โ "can I round-trip right now?"
// โ callers should use credentials.Probe, which performs a
// Set/Get/Delete canary under its own context.
func (b *Backend) Available() bool {
return b.client != nil && b.client.Token() != ""
}
3. Register at startup¶
In your tool's main package, register the backend before the first credential call. Two patterns:
Create yourtool/cmd/yourtool/vault.go:
package main
// Registers the Vault backend at process start. Requires
// VAULT_ADDR and VAULT_TOKEN in the environment. Delete this
// file to ship a build that uses the default (stub) backend.
import _ "yourtool/internal/vaultinit"
And yourtool/internal/vaultinit/vaultinit.go:
package vaultinit
import (
"log"
"os"
"gitlab.com/phpboyscout/go-tool-base/pkg/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 cmd/gtb/keychain.go pattern โ both register their respective backend via init(); later registrations win. Use the explicit form if you need the backend's construction to be config-driven (e.g. a flag selects Vault vs OS keychain).
4. Test it¶
Register your Vault backend in an integration test, run the resolver, assert the Store/Retrieve round-trip succeeds. For unit tests of code that depends on a credentials backend (resolvers, setup wizards), do not stand up a real Vault โ use pkg/credentials/credtest.MemoryBackend, which satisfies the same contract and runs entirely in-process:
import (
"testing"
"gitlab.com/phpboyscout/go-tool-base/pkg/credentials"
"gitlab.com/phpboyscout/go-tool-base/pkg/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 pkg/vcs.ResolveToken, pkg/chat.New, etc. and assert they
// pick up the stored value.
}
Round-trip integration tests for your actual Vault implementation belong behind an env-var gate (see Integration Testing): INT_TEST_VAULT=1 go test ./yourtool/credentials/vault/....
Composing backends¶
A common ask is "try Vault first; fall back to OS keychain if Vault is unreachable; fall back to stub if neither responds". credentials.RegisterBackend takes a single Backend at a time, so composition is implemented by writing a wrapping Backend:
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
}
if errors.Is(err, credentials.ErrCredentialNotFound) {
// try next backend
lastErr = err
continue
}
// unavailable / auth failure โ also try next
lastErr = err
}
return "", lastErr
}
// Similar for Store/Delete; Store typically writes to the first
// Available() backend only.
Register the chain once at startup:
credentials.RegisterBackend(&ChainedBackend{
backends: []credentials.Backend{
mustBuildVaultBackend(),
keychain.Backend{}, // from pkg/credentials/keychain
},
})
Current limitations¶
Worth knowing when designing a custom backend:
- No built-in retry. Transient network errors surface as-is; wrap your Vault client with retry logic before registering.
- No built-in caching. Each
Retrievehits the backend. Caching is your implementation's concern โ but be careful not to cache across token rotation. - Single active backend. Composition is via a wrapping
Backend(see above), not a registration chain. - Error taxonomy is thin.
ErrCredentialNotFoundandErrCredentialUnsupportedare the only sentinels. Auth failures and permission denials surface as wrapped errors; callers that want to distinguish them must inspect viaerrors.Ason your own error types.
Related¶
docs/components/credentials.mdโ architecture reference forBackend,RegisterBackend,Probe.docs/development/testing/manual-credentials.mdโ scenarios that exercise the active backend end-to-end.pkg/credentials/credtestโ in-process backend for unit testing.pkg/credentials/keychainโ the canonicalgo-keyringimplementation ofBackend, useful as a reference when writing your own.