Self-hosted credential custody and egress proxy. Hold third-party API credentials on behalf of your users, refresh and revoke them correctly, and make authenticated calls with them — without your application code ever touching a plaintext credential, and without a database dump yielding one.
One binary. Run it whole on a laptop with no configuration, or split it across machines when you need to. The code is identical either way.
make build
export KEEPLANE_UNSEAL_PASSPHRASE="something only you know"
./bin/keeplane doctor # creates its key and database, then checks itself
./bin/keeplane serve # every role, one process, no config file- What it is
- The roles
- How the pieces connect
- What is built today
- Try it in five minutes
- Single binary or many, by config
- Deploying it
- Documentation
- Development
An operator — you — runs keeplane. Your end users connect their GitHub, Slack or Stripe accounts to your product. keeplane holds those credentials, keeps them fresh, and lets your application make calls with them through a proxy that injects the credential on the way out.
Six things are in scope, and the rest is deliberately not:
- Run the OAuth or API-key authorization flow on a page you brand
- Store credentials under your root of trust, not a vendor's
- Refresh, rotate and revoke them correctly
- Proxy authenticated requests to providers, injecting credentials
- Enforce policy on every call — who, which scopes, which routes
- Record a tamper-evident audit of every credential use
Explicitly not: a unified data model across providers, a workflow engine, an identity provider, an observability backend, or a general secrets manager.
Only one component ever holds key material, and the compiler enforces it.
The code that can decrypt lives beneath internal/credcore/internal/, where
Go's internal rule refuses every importer outside credential-core. A proxy, a
worker or an admin UI that tried to open the vault directly would not fail
review — it would fail to build, and a committed test asserts exactly that.
So a database dump yields ciphertext and nothing else. The root key is a separate file the database holds no reference to.
One binary, selected with --role. In a single process they call each other
through Go interfaces; split up, the same calls travel over a unix socket or
mTLS, and no role's code knows which.
| Role | Owns | Never does | Docs |
|---|---|---|---|
| credential-core | The root key, envelope encryption, rotation, revocation, cryptographic shredding, the audit log | Expose a bulk read path. Return a secret from Resolve. Serve a public surface |
cred/README.md |
| catalog | What a provider is — its hosts, routes, grant type — and what each tenant has registered against it. Compiles the snapshot the request path reads | Accept a template, a parameterised host, or any code-shaped value. A profile is data, never code | catalog/README.md |
| connect | The OAuth and API-key flows an end user's browser sees; verify-before-active; re-auth links | Store a credential itself. Accept a per-request redirect_uri |
connect/README.md |
| worker | Leader-elected sweeps: token refresh, health checks, the audit sealer, retention | Run on every replica. Retry an ambiguous token exchange. Serve anything at all — it binds no port | docs/WORKER-DEPLOY.md |
| proxy | The request path: route resolution, the egress guard, credential injection, streaming | Read the catalog or a database per request. Follow a redirect. Take a host from a caller | proxy/README.md |
| api | The operator REST surface and admin UI; config apply; audit search; caller and certificate management. Hosts the catalog | Return decrypted material — admin does not carry Unwrap |
docs/API-DEPLOY.md |
api holds admin, which excludes Unwrap. A total compromise of the admin
surface can destroy credentials, not read them. Destruction is recoverable
through re-authorisation; disclosure is not.
ACTORS end user's your an operator elapsed
browser application (CLI / admin UI) time
│ │ │ │
▼ ▼ ▼ ▼
┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐
ROLES │ connect │ │ proxy │ │ api │ │ worker │
└──┬───┬──┘ └──┬───┬──┘ └──┬───┬──┘ └──┬───┬──┘
│ │ │ │ │ │ │ │
│ │ catalog.Reader │ │ snapshot poll │ │catalog.Writer│ │ Reader
│ └────────────────┼───┴────────┬────────┼───┴──────────────┼───┘
│ │ ▼ │ │
│ │ ┌───────────┐ │ │
│ │ │ catalog │◀─┘ │
│ │ └─────┬─────┘ │
│ │ │ │
cred:lifecycle cred:data-plane │ cred:admin │ cred:lifecycle
│ │ │ │ │
▼ ▼ ▼ ▼ ▼
┌───────────────────────────────────────────────────────────────────┐
│ credential-core │
│ Resolve · Unwrap · SecureSave · Renew · Rotate · Revoke · Shred │
│ the only holder of the unwrap identity │
└───────────────────────────────┬───────────────────────────────────┘
│ internal/ — compiler-enforced
▼
root key file (or KMS) + store
OUTBOUND connect ──► a provider's token endpoint
worker ──► a provider's token endpoint
proxy ──► a provider's API
all three leave the host only through the egress guard, and only
to a host literally named in an approved profile
Four properties are visible here, and each is a decision:
- Only credential-core touches key material. Every other arrow into it is an interface call.
- The proxy never reads the catalog on the request path. It polls a digest and swaps an in-memory snapshot, so a control-plane outage cannot fail a request in flight.
- No role reads another role's tables. Cross-role reads go through the contracts, which is what lets identical code run in every topology.
- The database is one file or one cluster, not a shared bus.
The arrows never change; only what they are made of changes.
| Topology | What an arrow is | When |
|---|---|---|
| Single binary | a Go interface call on a pointer | the default; a laptop, a small deployment |
| Separate processes, one host | HTTP over a unix socket, caller named by the kernel (SO_PEERCRED) |
you want OS-level isolation between roles |
| Separate nodes | HTTP/2 over TLS 1.3 with RequireAndVerifyClientCert |
roles on different machines |
Chosen per dependency in keeplane.toml — see
Single binary or many.
The composition root picks per dependency. No service's own code knows which, which is why one test body can assert identical behaviour across all three.
| Role | Stages | Status |
|---|---|---|
| credential-core | V1–V14 | ✅ built — both drivers, four topologies |
| catalog | C1–C8 | ✅ built — both drivers, three topologies |
| connect | N1–N8 | ✅ built — both drivers, three topologies |
| worker | W1–W8 | ✅ built — both drivers, three topologies |
| proxy | P1–P8 | ✅ built — both drivers, three topologies |
| api | A1–A8 | ✅ built — both drivers, three topologies |
Smoke suites accumulate; each service's last stage adds one, and none regresses.
keeplane smoke --suite s1 # credential-core: save → reveal → rotate → revoke → shred
keeplane smoke --suite s2 # catalog: publish → register → snapshot → poll
keeplane smoke --suite s3 # connect: start → callback → verify → store → reauth
keeplane smoke --suite s4 # worker: connect → discover → refresh → seal → verify the chain
keeplane smoke --suite s5 # proxy: inject → stream → refuse → swap under load
keeplane smoke --suite s6 # api: plan → refuse → apply → list → audit → verifySuites are numbered by service rather than by build order, so one never has to be renumbered when the order changes — as it already did once, when the proxy took s5 before the worker's s4 existed.
577 test functions, passing on SQLite and Postgres, clean under -race.
No configuration, no third-party account, no browser.
make build
export KEEPLANE_UNSEAL_PASSPHRASE="something only you know"
# 1. Start every role, plus a fake provider on 127.0.0.1:9099.
# The sandbox is opt-in and never part of "all".
./bin/keeplane serve --role=all,sandbox &
# 2. What providers does this build know?
./bin/keeplane catalog list
# 3. Register an integration. The client secret goes under custody first;
# the catalog only ever stores a reference to it.
./bin/keeplane cred save --tenant default --ref sandbox-secret --value 'sandbox-client-secret'
./bin/keeplane catalog integrations add --tenant default --ref sandbox \
--profile sandbox --client-id demo --secret-ref default/sandbox-secret
# 4. Run a real OAuth flow. The sandbox redirects straight back, so curl
# completes what a browser would.
URL=$(./bin/keeplane connect start --ref demo --integration sandbox | grep -o 'http[^ ]*')
curl -sL "$URL" | grep -o '<h1>.*</h1>' # <h1>Connected</h1>
# 5. The credential is under custody — access and refresh, separately.
./bin/keeplane connect status --ref demo
./bin/keeplane cred list
# 6. Now USE it, without ever holding it. Issue a caller for your application,
# then make the call through the proxy. A running proxy picks up a new
# caller within one refresh interval, so give it a second.
TOKEN=$(./bin/keeplane proxy caller issue --name demo-app --refs demo | grep -o 'kpc_[A-Za-z0-9_-]*')
sleep 3
curl -s -H "Authorization: Bearer $TOKEN" -H "X-Keeplane-Ref: demo" \
http://127.0.0.1:8081/p/sandbox/api/whoami{"expires_at":"2026-08-31T05:49:28.207162Z","subject":"sandbox-user"}That call reached the provider with a real credential attached. $TOKEN is not
that credential — it identifies the application, reaches only the connections
it was issued for, and is worth nothing at the provider.
The same request can name an operation from the connector instead of a path, which is the shape a workflow engine usually wants:
curl -s -X POST -H "Authorization: Bearer $TOKEN" -H "X-Keeplane-Ref: demo" \
-H 'Content-Type: application/json' -d '{}' \
http://127.0.0.1:8081/execute/sandbox/operations/whoamiPOST /execute/{app}/operations/{operation} with path_params, query_params
and data. It reaches nothing /p/ cannot — it builds a path from the same
route table and hands it to the same matcher — and the provider's method comes
from the route, never from the caller. On by default;
[proxy.execute] enabled = false closes it.
Or all of it in one command: ./bin/keeplane smoke --suite s3, then
--suite s5.
Full walkthroughs: cred/README.md for custody,
catalog/README.md for providers and integrations,
connect/README.md for the flows themselves, and
proxy/README.md for using a credential without holding it.
The topology is one setting per dependency. No code changes, no build flags,
no separate binaries — every service consumes interfaces, so none of them can
tell whether it got a pointer or a client, and the composition root reads the
answer from keeplane.toml.
Set nothing. Every role runs in one process and talks to the others through method calls.
keeplane serve --role=all# On the credential-core node: serve a listener and declare who may call it.
[vault.listen]
transport = "unix" # or "tcp" with TLS, for another host
socket = "/run/keeplane/credcore.sock"
[[vault.peers]]
identity = "uid:1002" # or a certificate CN over TCP
role = "data-plane" # Resolve and Unwrap; nothing else
tenants = ["*"]# On the proxy node: reach it instead of building one.
[vault.client]
addr = "/run/keeplane/credcore.sock" # or "https://credcore.internal:8443"
# cert/key/ca as well, for an https target — a network peer is identified by a
# certificate, and there is no equivalent of "same uid" across a host boundary.
[proxy.listen]
addr = "0.0.0.0:8081"keeplane serve --role=proxy # no passphrase, no root keyIssue at least one caller before starting a proxy on a non-loopback address — it refuses to bind one with none, since an open proxy that trusts nobody is a proxy that will shortly trust the first thing that asks.
INFO credential-core is remote addr=/run/keeplane/credcore.sock role_wanted=data-plane
INFO proxy listening at=0.0.0.0:8081
That node has no root key and never unseals one. It is the difference
between a split that is real and one that is only drawn in a diagram, and it is
asserted by TestRemoteVaultNeedsNoRootKey.
| Setting | What this process reaches remotely |
|---|---|
[vault.client] addr |
credential-core — credentials, the audit trail, the seal chain |
[catalog.client] addr |
the catalog — profiles, integrations, the compiled snapshot |
[connect.client] addr |
connect — connection listing, re-auth marking, state expiry |
Mix freely. A worker node might dial credential-core and connect while hosting nothing; an api node hosts the catalog and dials the rest.
The database is shared, and stays local. Splitting means splitting the root key and the process boundary, not the store — every role owns tables in it. Each node opens the same database directly.
A process presents ONE identity, so it gets ONE role. With a local
credential-core, the proxy holds data-plane and api holds admin inside the
same binary. Reaching a remote one, the role is whatever that node's peer
entry grants — so co-locating the proxy and api on a split node means one
credential covering both. Give each split role its own node, or accept the
union knowingly. Over a unix socket the identity is the uid, so processes
sharing a user share a role.
api hosts the catalog. An apply and its snapshot rebuild commit together; splitting them would make that atomicity a distributed problem for no benefit.
Caller management needs api and the proxy together. The proxy's caller
routes are declared but not served, so [proxy.client] does not exist yet — api
issues and revokes callers in-process.
Full worked configurations: docs/DEPLOYMENT.md.
keeplane is built for one operator serving many tenants — a SaaS vendor whose customers each connect their own accounts at Asana, Airtable and the rest.
| Operator | Tenant | |
|---|---|---|
| onboard an app into the catalogue | ✅ CLI and API | ❌ |
| approve a new egress host | ✅ | ❌ |
| see the apps on offer | ✅ any tenant's view | ✅ their own |
| connect an app, re-authorise, revoke | ✅ | ✅ their own only, over /v1/tenant/* |
| use the admin console | ✅ /admin/ |
❌ — the SaaS builds its own |
| execute a request | — | ✅ their own only |
Publishing is the offer. An app the operator publishes is in every tenant's marketplace immediately — there is no per-tenant entitlement step, and no operator action when a new customer connects their tenth app:
# the operator, once
keeplane catalog publish asana.json --approve --by ops
keeplane proxy caller issue --tenant '*' --name crm --refs asana# any tenant thereafter, over HTTP, with no operator involved
curl -X POST :8079/v1/tenant/apps -H "Authorization: Bearer $CRM" -H "X-Keeplane-Tenant: hdfc" -d '{}'
curl -X POST :8079/v1/tenant/connect -H "Authorization: Bearer $CRM" -H "X-Keeplane-Tenant: hdfc" \
-d '{"app":"asana"}' # → a URL to open in the tenant's browserA tenant that has not connected an app still sees it, and is refused at the
request path by the absence of a credential rather than by a permission
table. What that looks like end to end, with the rows written at every step:
docs/REQUEST-LIFECYCLE.md.
keeplane serve --role=api also serves a server-rendered admin console — the
same process, the same port, the same identities and capability checks as the
API, and no credential of its own.
/admin/ |
overview |
/admin/connections |
connections, with revoke |
/admin/integrations |
what each tenant may reach |
/admin/audit |
the audit trail, keyset-paged |
/admin/callers |
caller tokens |
/admin/config |
compose, review and apply configuration — needs plan |
/admin/operators |
operators |
/admin/certificates |
issued certificates |
It loads nothing — no font, no icon, no analytics, script-src 'none' — because
the list of providers a company uses is commercially sensitive before it is
technically sensitive, and because the console has to work during the incident
that made you open it. Viewing a list and changing it are separate
capabilities: a viewer sees the operators, and the add and disable forms are
not rendered — a forged POST is 403.
To reach it from a browser you need TLS and a client certificate. Navigation
cannot set an Authorization header, so OIDC, the static token and the
bootstrap grant are reachable by curl and by nothing you can type in a URL
bar:
keeplane pki issue --identity api --kind server --host 127.0.0.1,localhost
keeplane pki issue --identity alice --kind client --use console # prints the rest[api.listen]
addr = "127.0.0.1:8079"
tls_cert = "./pki/api.crt"
tls_key = "./pki/api.key"
client_ca = "./pki/ca.crt" # without tls_cert this is refused at start-upThen https://127.0.0.1:8079/admin/. An empty directory to a console in a
browser, start to finish:
docs/ADMIN-CONSOLE-QUICKSTART.md.
Never deployed this before? Start with one of the two step-by-step guides. They assume no prior knowledge, and each is self-contained — you need no other document to get from an empty machine to a real proxied request against Asana and Airtable:
| For | |
|---|---|
docs/INTEGRATION-SETUP-SINGLE-POSTGRES.md |
one box, Postgres, both connectors, both connection kinds, reads and writes. ~45 minutes |
docs/INTEGRATION-SETUP-MULTI-POSTGRES.md |
four nodes: Postgres, a keyed vault node, an api node, and a proxy node holding no key. mTLS throughout. ~90 minutes |
docs/ADMIN-CONSOLE-QUICKSTART.md |
just the console: an empty directory to the admin UI in a browser, with the certificate steps. ~10 minutes |
Then docs/DEPLOYMENT.md — the whole system on
one box and across several, with the peer declarations and certificates each
needs. Then the per-role documents below.
For credential-core specifically, docs/VAULT-DEPLOY.md — topologies,
dedicated users and file modes, a systemd unit, certificates and their rotation,
connection limits, backups, and an incident runbook.
Two things worth knowing before anything else:
- A failed unseal is never fixed by deleting the key file. It means the
wrong passphrase or a corrupted file. Deleting it makes
doctorgo green instantly and permanently orphans every credential. - Back the root key up separately from the database. Storing them together recreates exactly the situation this design exists to prevent.
Per role — how to run it, use it, and what it guarantees:
cred/README.md |
credential-core: getting started, configuration, using it from an application, the CLI, across hosts, the audit trail, unseal recovery |
catalog/README.md |
catalog: providers, integrations, writing your own profile, the egress approval gate, the sandbox |
connect/README.md |
connect: the OAuth flow, api-key intake, connection statuses, re-authorisation, the two listeners |
credential-core, in depth:
docs/VAULT-DEPLOY.md |
production deployment and the incident runbook |
catalog, in depth:
docs/CATALOG-DEPLOY.md |
the listener and its two roles, getting profiles into production, rolling a definition forward, the tamper halt |
connect, in depth:
docs/CONNECT-DEPLOY.md |
running it: public_url and provider registration, TLS termination, the two listeners, scaling out, what to watch |
worker, in depth:
docs/WORKER-DEPLOY.md |
running it: no listener, leases and failover, what to watch, dead letters, the unwrap ceiling, the incident runbook |
api, in depth:
docs/API-DEPLOY.md |
running the operator surface: the bootstrap token, operators and RBAC, the identity mechanisms, the admin console and how to reach it from a browser, what to watch |
proxy, in depth:
docs/PROXY-DEPLOY.md |
running it: issuing callers, what the errors mean, what to watch, sizing the unwrap ceiling, scaling out |
Cross-cutting:
docs/INTEGRATION-SETUP-SINGLE-POSTGRES.md |
from an empty machine to a real Asana and Airtable request on one box with Postgres — assumes no prior knowledge |
docs/INTEGRATION-SETUP-MULTI-POSTGRES.md |
the same, split across four nodes with mTLS, where the proxy node holds no key material |
docs/ADMIN-CONSOLE-QUICKSTART.md |
an empty directory to the admin console in a browser: the CA, the client certificate, the keychain steps, and what each page should show |
docs/API.md |
the API reference: every HTTP surface, who may call it, request and response bodies, and error codes |
docs/REQUEST-LIFECYCLE.md |
one real Airtable request from an empty database to the provider and back, with the rows written at every step and what a second, unconnected tenant gets |
docs/DEPLOYMENT.md |
the whole system: one box production-shaped, what each role needs, and exactly which topologies the binary reaches today |
docs/END-TO-END-WALKTHROUGH.md |
the whole system from an empty directory, as an iPaaS would use it: connector documents, credentials, both connection kinds, the proxied request, and what every step persists |
docs/PACKAGE-LAYOUT.md |
where code goes: the three tiers, naming rules, what is inside the custody boundary, migration ranges |
examples/README.md |
the Python and Java clients, and what a client should and should not do |
api/openapi.yaml |
the wire contract, generated from the server's own types |
make test # unit and integration tests
make race # the same, under the race detector
make test-postgres # the same assertions against Postgres
make verify # keeplane-testclient against keeplane.toml
make openapi-check # the published contract still matches the server's typesEvery change runs on both drivers. Anything concurrent runs under -race
with -count above 1 — a single pass proves nothing about a race.