Self-hosting xecret
Run xecret yourself — the honest dependency list, the root-key ceremony, a step-by-step deployment, and what you are signing up to operate.
Updated 2026-10-14 · read this page as markdown
On this page
xecret is AGPL-3.0 and designed to be run by people other than us. This page is the honest version of what that takes, including the parts that are friction, because a secrets manager that hides its operational costs is not being honest about the one thing it sells.
The dependency list, stated plainly
| Dependency | Role | Required? |
|---|---|---|
| Cloudflare Workers (paid plan) | Runs the web app and API. The paid plan's CPU limits are assumed. | Yes |
| PostgreSQL (Neon recommended) | All data. Hyperdrive in front of it on Cloudflare. | Yes |
| Cloudflare Secrets Store | Holds the runtime copy of the root key, bound to the Worker. | Yes |
| A WorkOS account | Identity only — AuthKit's hosted pages handle password, Google, MFA and enterprise single sign-on (though single sign-on connections cannot be set up from xecret yet). xecret checks the result once, server to server, and issues its own sessions. | Yes, and this is real friction. AuthKit is free up to a million monthly active users; enterprise SSO connections are billed by WorkOS. An identity-provider interface exists so another provider can be contributed. |
| A mail provider | PIN-reset links, invitation emails, and the archive emailed before every vault reset. | Yes, in production — a production deployment without one resets no vault, because a vault is never reset without its archive being emailed first; the reset screen says so. Elsewhere optional: invitations return a shareable link instead, PIN reset answers sent: false with a reason, and a vault resets only when the person ticks "Reset without the emailed archive". |
| A deploy-time secret source | Where your copy of the root key lives between deploys. The Worker never calls it at runtime. | Your choice — anything that can populate environment variables at deploy time. |
If that list looks like a lot for a tool that injects environment variables: it is, and it is stated up front for that reason.
Before anything else: the root key
The root key encrypts every organisation's key, which encrypts every environment's key, which encrypts every secret.
Generate and split it.
npm run keygen # generates the key plus 2-of-3 Shamir sharesEscrow the shares in physically separate places. A USB stick in a safe, a printed sheet in a different building, a second site. Never all digital, never all in one place, never all held by one person.
Write down who holds what, and test a restore. A key-recovery drill that has never been run is a key-recovery plan you do not have. Run it quarterly.
Deployment
1. Database
Create a PostgreSQL database and the restricted application role, then run migrations.
npm ci
npm run db:migrate # reads DATABASE_URL / MIGRATION_DATABASE_URLTwo roles, deliberately: migrations run as a role that can change the schema, and the application runs as one that cannot — and in particular cannot alter the audit table, which is what makes "append-only" a grant rather than a promise.
2. Identity: a WorkOS account
xecret sends people to WorkOS AuthKit's hosted pages to sign in, checks the result exactly once, server to server, and from then on uses its own session. No identity-provider code runs in the browser, and nothing about the provider is needed when the app is built. Set it up in the WorkOS dashboard:
Pick the environment. A WorkOS account has a Staging and a Production environment, and they share nothing — not users, not keys. Use Staging for local development and staging deployments, Production for production. Every value below must come from the environment the deployment signs people in against; a key from one and a client id from the other fails every sign-in in a way that looks like a bug and is not.
Register the redirect URI, exactly
<your origin>/api/auth/callback— for examplehttps://secrets.your-company.com/api/auth/callback, andhttp://localhost:3030/api/auth/callbackin Staging for local development. xecret derives it fromXECRET_PUBLIC_URL, so there is nothing to configure on the xecret side; a mismatch with the dashboard is a redirect AuthKit refuses.Set the sign-in endpoint to
<your origin>/api/auth/login. When AuthKit needs xecret to start a sign-in rather than finish one, it sends the browser there. A sign-in xecret did not start is refused as expired.Enable the sign-in methods — email and password, and Google. Staging can use WorkOS's demonstration Google credentials; Production needs your own Google OAuth client, created in the Google Cloud Console with the redirect URI WorkOS shows you, and its client id and secret entered in WorkOS. MFA is a WorkOS setting too, and recommended for organisation owners (see the warning below); with an authenticator app enrolled, resetting a vault also asks for its code. Enterprise single sign-on cannot be set up from xecret yet: the organisation settings that create the WorkOS organisation, hand a customer's IT admin the setup link, and make single sign-on required are not built.
Enable Magic Auth, and leave WorkOS's default Magic Auth email on. Resetting a vault asks for a one-time code that WorkOS creates and emails, and WorkOS refuses to create one while Magic Auth is disabled — so with it off, nobody can reset a vault. Enabling it also offers "email me a code" as a way to sign in; that never counts as the reset's first check, which counts only a password, a passkey, Google or single sign-on — no other OAuth provider.
Collect three values. The client id (
client_…); the environment's secret API key; and a cookie password you generate yourself, at least 32 random characters:openssl rand -base64 32The cookie password signs the short-lived cookie that ties a sign-in to the browser that started it. It must not be the API key. Do not judge which environment an API key belongs to by its prefix — check where you copied it from.
The client id is not a secret — it appears in every sign-in URL — and goes in
wrangler.toml (step 4). The API key and the cookie password are secrets and go
on the Worker:
npx wrangler secret put WORKOS_API_KEY --env production
npx wrangler secret put WORKOS_COOKIE_PASSWORD --env productionPut the client id in your deploy-time secret source as well. The config
checker in step 5 reads it from there, and npm run check:deploy compares that
copy with the one in wrangler.toml.
Without any of the three, nobody can sign in, and the sign-in pages say so — the browser lands on the sign-in page's "unavailable" error — rather than guess.
3. Cloudflare
Create:
- a Hyperdrive binding for the database (pointed at the direct endpoint — Hyperdrive pools, so a pooling endpoint must not be used underneath it);
- the rate-limit bindings;
- a Secrets Store entry holding the root key.
The repository's wrangler.toml names every binding the code expects, and
.env.example names every variable.
4. Tell the deployment it is yours
apps/web/wrangler.toml is committed to the repository, and everything
account-specific in it is ours — the domain, and the ids of the resources
step 3 just told you to create. Nothing you have done so far has changed any of
it, and nothing later on this page checks it for you. Edit it now, before the
first deploy:
[env.production]
name = "xecret-production"
routes = [{ pattern = "secrets.your-company.com", custom_domain = true }]
hyperdrive = [{ binding = "HYPERDRIVE", id = "<your hyperdrive id>" }]
[env.production.vars]
XECRET_ENV = "production"
XECRET_PUBLIC_URL = "https://secrets.your-company.com"
WORKOS_CLIENT_ID = "<your WorkOS Production client id>"
# …the rest as shipped
[[env.production.secrets_store_secrets]]
binding = "XECRET_ROOT_KEYS"
store_id = "<your secrets store id>"
secret_name = "XECRET_ROOT_KEYS"The ids are not credentials — they are useless to anyone without an API token
for the account that owns them, which is why they are committed at all. But
left as shipped, wrangler deploy authenticates as you and then addresses
resources belonging to us, and the failure reads like a permissions problem
rather than a copied config. The WorkOS client id is the same: left as shipped,
every sign-in goes to our WorkOS environment, which knows nothing of your
redirect URI and refuses it. If you also run a staging deployment or develop
locally, put your Staging client id in [env.staging.vars] and the
top-level [vars] the same way.
XECRET_PUBLIC_URL is read twice, and both readings matter:
- During
next build. Every documentation and marketing page is prerendered, so its canonical URL, itssitemap.xmlentry, theHostline inrobots.txtand the JSON-LD@idare all written before the Worker exists. Left as shipped, your deployment tells every crawler that our site is the canonical version of your pages. - At runtime, by the Worker. It is what the
Originheader on every mutation is compared against — deliberately, rather than the request's ownHost, so that an attacker who can reach the Worker cannot choose the value the check uses. It is also the base of the links in invitation and PIN-reset emails, and of the sign-in redirect URI. Left as shipped, mutations from your own dashboard are rejected as cross-site, the invitation you send a colleague points at a server you do not control, and AuthKit is asked to send people back to a callback your WorkOS environment does not list.
The two are kept identical by construction: the deploy script reads
XECRET_PUBLIC_URL out of this file and hands it to the build, so there is one
place to change and no second copy to forget.
5. Verify before deploying
Both checks read the deployment's variables from their own environment, so run
them under the environment you are about to deploy. With this repository's
Phase.dev setup that means naming it, because .phase.json defaults to
Development:
phase run --env Production -- npm run check:env # states exactly what is missing or malformed
phase run --env Production -- npm run smoke # proves the whole stack against the real databaseWithout Phase, export the same variables into the shell first —
set -a; . ./.env; set +a — and then run npm run check:env and
npm run smoke. Run both. The config checker catches the missing binding you
will otherwise discover from a 503 in production.
When the WorkOS values are set, the config checker also makes one network call
to prove that the API key and the client id belong to the same WorkOS
environment: it presents WorkOS with a deliberately bogus sign-in code, and
WorkOS's answer says whether it accepted the pair and refused only the code
(they match) or refused the pair (they do not). Nothing is inferred from the
key's prefix. npm run check:env -- --offline skips the call.
6. Deploy
phase run --env Production -- sh scripts/deploy-web.sh productionThe phase run --env Production -- prefix is how this repository populates
the deploy-time environment, naming the same environment step 5 checked;
substitute Doppler, a CI secret block, or set -a; . ./.env. The script does
not care which.
It reads XECRET_PUBLIC_URL and XECRET_ENV out of wrangler.toml for the
environment you named, exports them into the build, and refuses to go on if
that environment declares no WORKOS_CLIENT_ID — the Worker reads it at runtime
and the build never does, so nothing else would notice it missing until every
sign-in failed. Then it prints what it resolved before it starts:
Building for production at https://secrets.your-company.com
(from apps/web/wrangler.toml → env.production.vars — stop now if that is not your deployment)
(signing in against WorkOS client client_… — its API key must be from the same WorkOS environment)Read those lines. If they do not name your domain and your WorkOS client, step 4 has not been done, and everything after this point publishes somebody else's.
If you build without the script
Both variables have to be in the build environment, not only in the Worker's bindings, for the reasons in step 4:
XECRET_PUBLIC_URL=https://secrets.your-company.com \
XECRET_ENV=production \
npx opennextjs-cloudflare build --env production
npx opennextjs-cloudflare deploy --env productionSet XECRET_ENV even though it looks redundant beside --env production. Two
things make it load-bearing. --env puts nothing into the build's environment
— it selects which environment's compatibility_date and assets settings the
adapter reads, and none of that reaches next build. And the build only
refuses to guess an origin when XECRET_ENV names a deployment: with
XECRET_ENV unset it does not error at all, it quietly falls back to
http://localhost:3030 and writes that into the canonical URL of every page it
prerenders. XECRET_ENV is what converts that silent wrong answer into a build
failure, which is the only reason to type it.
7. Point clients at it
xecret login --api-url https://secrets.your-company.comThe URL is stored with the credential. In CI, set XECRET_API_URL beside
XECRET_TOKEN. See configuration.
What you are now operating
Backups. The database holds only ciphertext; a backup without the root key escrow is a paperweight. Test restore of both.
Migrations. Generated SQL, committed and reviewed, applied with
npm run db:migrate. Never auto-applied on deploy.The audit log. Append-only and partitioned by quarter, with the child tables kept in the
audit_partsschema. Migration 0010 pre-creates eight quarters, counting the current one, and nothing extends that automatically yet, so run this before the runway ends — once a quarter's rows land in the default partition, that quarter can no longer be given a real partition:SELECT create_audit_log_partition(d::date) FROM generate_series( date_trunc('quarter', now() AT TIME ZONE 'UTC'), date_trunc('quarter', (now() + interval '21 months') AT TIME ZONE 'UTC'), interval '3 months' ) AS d;Run it as the owner of the audit tables — it issues
CREATE TABLEandGRANT, and the application role holds neither. It fills every quarter to the end of the runway, so it is idempotent and any cadence shorter than 21 months is safe. If any quarter in the range already has rows in the default partition, the whole statement aborts and no quarter is created — recover those first, oldest first, then re-run it.Recovering one is a single transaction, as the table owner, substituting that quarter's UTC bounds. Detaching is what makes the
CREATE TABLEpossible; theDELETEis what makes the re-attach possible; and no audit row is destroyed, because every one is written back through the parent before it is removed:BEGIN; ALTER TABLE public.audit_logs DETACH PARTITION audit_parts.audit_logs_default; SELECT create_audit_log_partition(DATE '2028-07-01'); INSERT INTO public.audit_logs SELECT * FROM audit_parts.audit_logs_default WHERE created_at >= TIMESTAMPTZ '2028-07-01 00:00:00+00' AND created_at < TIMESTAMPTZ '2028-10-01 00:00:00+00'; DELETE FROM audit_parts.audit_logs_default WHERE created_at >= TIMESTAMPTZ '2028-07-01 00:00:00+00' AND created_at < TIMESTAMPTZ '2028-10-01 00:00:00+00'; ALTER TABLE public.audit_logs ATTACH PARTITION audit_parts.audit_logs_default DEFAULT; COMMIT;Do it in a maintenance window:
DETACHtakesACCESS EXCLUSIVEonpublic.audit_logsand holds it toCOMMIT, so audit writes block throughout, and a blocked audit write fails the request that produced it.That covers the case you are likely to meet. Two rarer ones — a partition left detached by an interrupted repair, and a partition attached over the wrong range because it was created from a non-UTC session — need a wider procedure, in
docs/operations/database-setup.mdin the repository. The migration names that file in the error it raises for each.Mail, monitoring and error reporting. Yours to wire. The log pipeline contains no secret values by construction, but where the logs go is your decision.
The identity provider. Keep each WorkOS environment's redirect URI in step with
XECRET_PUBLIC_URL. Rotate the API key in the WorkOS dashboard, then withwrangler secret put; rotating the cookie password costs only the sign-ins in flight at that moment. Signing out of xecret does not end somebody's AuthKit session, so a returning person may not be asked for a credential. Resetting a vault asks AuthKit for a fresh sign-in, and the server checks the sign-in really happened during that request, but the check never reaches a Google or single sign-on session — see the warning in step 2.Key rotation. Every layer of the hierarchy is versioned so it can be rotated independently. Have a runbook before you need one.
Webhook egress. A webhook is a URL an organisation admin types and your deployment then fetches. xecret refuses
http, credentials in the URL, local names and every private, loopback, link-local, reserved and IPv6 transition-mechanism address literal, before every attempt, and never follows a redirect. It does not resolve DNS, so a public name that points at a private address — or is re-pointed at one later (DNS rebinding) — gets through that check. On Cloudflare Workers the platform closes the gap: a Worker cannot reach a private network or a metadata endpoint. Anywhere else it is open, and the server would send requests into your own network. Put an egress control in front of it that judges the address after resolution — a forward proxy that refuses private destinations, or a network policy denying private, link-local and metadata egress — and until you have one, do not create webhook endpoints.
The trust model you are accepting
Self-hosting does not change the encryption model — it changes who you are trusting. Your deployment can decrypt the secrets it stores; that is what makes team sharing, CI tokens and browser import work.
What moves is the trust: from us to your own Cloudflare account, your own database, and your own key custody. Read what xecret can and cannot see before you accept other people's production credentials into a deployment you operate.
Licensing
The server is AGPL-3.0. The CLI is MIT.
In plain terms: run xecret yourself freely. If you modify the server and offer it to others as a network service, publish your changes. The CLI is unencumbered so it can live inside your Docker images and CI pipelines without licence friction.
Next
- Trust model — the model you are taking on.
- Configuration — pointing clients at your deployment.
- The audit log — what your deployment records.