@yashau/prick
v2026.1031.1
Published
Portable Runtime Injection of Cloudflare (stored) Keys — command-line client for a self-hosted secrets manager on Cloudflare Workers and D1.
Maintainers
Readme
Portable Runtime Injection of Cloudflare (stored) Keys
A self-hosted secrets manager that runs entirely on your own Cloudflare account. One Worker, one D1 database, and nothing else to operate.
Install · Examples · Access control · Getting started · Quickstart · CLI reference · Docs
prk login https://secrets.example.com
prk secrets set DATABASE_URL --project api --env production # prompts, masked
prk run --project api --env production -- ./deploy.sh # injected, never written to diskThe name is the job description: keys live in your Cloudflare account, and prk injects them into a
process at runtime — portably, and without ever touching disk.
🧩 What it is
| | |
| :---------- | :--------------------------------------------------------------------------------------------------------------------------- |
| prk | The command-line client: one static Rust binary, talking HTTP to your Worker. |
| Web UI | A SvelteKit admin console served from the same Worker. |
| Auth | Cloudflare Access — SSO for people, service tokens for CI. Identity comes from the signed JWT. |
| Storage | D1. Values are AES-256-GCM, and each ciphertext is cryptographically bound to the row that holds it. |
| MCP | An MCP server, so a coding assistant can manage secrets without reading them. |
⚙️ How it works
prk ─────┐ ┌───────────────────────────────┐
├──▶ Cloudflare ────▶│ Worker │
browser ─┘ Access │ · verifies the signed JWT │──▶ D1
(SSO / tokens) │ · resolves grants in D1 │ (encrypted)
│ · encrypts, decrypts, audits │
└───────────────────────────────┘Everything sits behind one Access-protected hostname. Access authenticates at the edge before the Worker runs; the Worker independently verifies the signed JWT to learn who is calling, then consults its own grant table to decide what they may do.
📦 Install
prk is one static binary. Every channel below serves the same prebuilt artefact from the
release page, and every one of those is
provenance-attested — gh attestation verify <archive> --repo yashau/prick checks it against this
repository.
brew install yashau/prick/prk # macOS, Linux
cargo binstall prk # anywhere with a Rust toolchain
npm install -g @yashau/prick # anywhere with Node
winget install yashau.prick # WindowsScoop takes the bucket first:
scoop bucket add prick https://github.com/yashau/scoop-bucket
scoop install prick/prkcargo install prk builds from source instead, and the
release page carries plain archives for eight
platforms with a SHA256SUMS and an SPDX SBOM beside them.
If you already use mise, let it own the install:
mise use -g npm:@yashau/pricknpm's global prefix sits inside the active Node's install directory, so a plain npm install -g
lands somewhere that is only on PATH while mise is activated — and moves the day mise bumps Node.
mise's own npm backend puts a prk shim in the shims directory that is already on your PATH, and
keeps it there across Node upgrades.
The npm route adds a Node process to every invocation, which prk doctor reports; the direct
installs above avoid it. See Install the CLI for the whole
picture, shell completions included.
💻 Examples
Deploying takes five commands — see Getting started — and everything below
assumes you have run prk login.
[!TIP] Set the project and environment once and the flags disappear from every command for the rest of the day. A
direnvfile per repository does the same thing permanently.export PRK_PROJECT=api PRK_ENV=production
prk run -- npm startSecrets reach the child through its environment block and nowhere else: no temporary .env, no
fifo, nothing written to disk. Everything after -- belongs to the child, so its own flags are
unambiguous even when prk understands them too:
prk run -- npm test --json -qprk run never invokes a shell — that is what keeps quoting predictable — so ask for one when you
want one:
prk run -- sh -c 'migrate && npm start'On Unix prk replaces itself with the child via execvp, so the exit code and signal disposition
are the command's own and a script cannot tell it ran under prk run.
Check what went in, without seeing values:
prk run -vv -- node -e 'console.log("up")'injecting 12 secrets into the child environment
variables: DATABASE_URL, REDIS_URL, STRIPE_SECRET_KEY, …
up⚠️ The value is never an argument. An argument lands in your shell history and is visible in
psoutput to every user on the machine. The masked prompt and--stdinare the two ways in.
prk secrets set STRIPE_SECRET_KEY --description "Live mode, rotates quarterly"Value for STRIPE_SECRET_KEY:
Added `STRIPE_SECRET_KEY` (rev 12).Or pipe it, which is the same write without a terminal:
openssl rand -hex 32 | prk secrets set SESSION_SECRET --stdinDescriptions are stored in plaintext beside the key name, so they are safe to read in a listing — and must never contain a value.
prk projects create "API service" --slug api
prk env create Production
prk env create StagingCreated project `API service` (api).
Created environment `Production` (production).
Created environment `Staging` (staging).The slug is what every command, URL and --scope addresses, and it is permanent. The display
name is free text and can be renamed.
🛑
uploadreplaces the environment by default — keys the file does not name are deleted. Look before you leap.
prk secrets upload .env --dry-run3 added, 1 changed, 2 removed (dry run; nothing was written).Once it says what you expected:
prk secrets upload .env --reason "initial import from the repo"
git rm --cached .env && rm .env--reason is copied verbatim into the audit row. Use --merge to add keys without removing the
ones the file leaves out. The whole file goes in one request, as one transaction with its audit row
inside it — so a failed import leaves the environment exactly as it was.
export DATABASE_URL="$(prk secrets get DATABASE_URL)"prk secrets get prints the bare value on stdout and nothing else, so it composes. Every reveal is
audited, and the audit row records why: reveal, copy, export or run. That is what lets a
reader tell "someone looked at it" from "someone took it".
prk secrets download --format env --output .env
docker run --env-file .env myimage
rm -f .env--output creates the file 0600. Prefer prk run, which writes nothing — or forward named
variables into the container instead, since a container does not inherit the docker client's
environment:
prk run -- docker run -e DATABASE_URL -e STRIPE_SECRET_KEY myimageFour export formats are available — env, shell, yaml and json — and every one of them quotes
unconditionally rather than "when necessary". Conditional quoting means modelling the consumer's
grammar exactly, and a single miss is either a command injection or a silently altered value.
Point the job at prick and let it 404 on the first run. That refusal is not a mistake — it is how
a service token introduces itself:
prk access identities --deniede367826f93b8d71185e03fe518aff3b4.access service 1 attempt(s)There is the subject, so nobody copies an opaque hex string between two consoles:
prk access grant e367826f93b8d71185e03fe518aff3b4.access --role reader --scope api:production
prk access rename e367826f93b8d71185e03fe518aff3b4.access "api deploy job"Name it while you still know what it is. An access list of hex strings is how a stale token survives three audits: nobody could say what it was for, so nobody was willing to be the one who removed it.
Then, in the workflow:
env:
PRK_API_URL: https://secrets.example.com
PRK_PROJECT: api
PRK_ENV: production
PRK_ACCESS_CLIENT_ID: ${{ secrets.PRICK_CLIENT_ID }}
PRK_ACCESS_CLIENT_SECRET: ${{ secrets.PRICK_CLIENT_SECRET }}
steps:
- run: prk run --no-input -- ./deploy.sh--no-input fails immediately instead of blocking on a prompt no CI job can answer.
prk secrets history DATABASE_URLv4 set [email protected]
v3 set [email protected]
v2 delete [email protected] DELETED
v1 set [email protected]Tombstones are shown rather than skipped — "this key was deleted at version 2" answers half the questions this command gets asked.
prk secrets rollback DATABASE_URL --to 3 --reason "bad connection string in v4"Restored `DATABASE_URL` from version 3 as version 5 (rev 46).The old plaintext is re-encrypted as a new version bound to it, so an old ciphertext blob replayed into the current row still fails its tag check.
prk access explain [email protected][email protected] user
groups contractors, platform
billing:production admin via group `platform` on `billing:*`
reader a direct grant on `billing:production`
-> admin group `platform` on `billing:*`-> marks the grant the server reported as decisive — the one to remove. Revoking Bob's direct
reader grant would change nothing; taking him out of platform is the answer.
prk access disable [email protected]One write, checked before grants are resolved, so it outranks every grant at every scope —
direct, group-held and BOOTSTRAP_ADMINS alike. Nothing has to be enumerated, so nothing can be
missed.
prk access enable puts it all back exactly as it was, because disabling never touched the grants.
Run prk access explain first to see what re-enabling would restore.
--json is a contract rather than a formatting flag:
| Outcome | stdout | stderr | | ------- | ----------------- | ----------------------- | | Success | one JSON document | empty | | Failure | empty | one JSON error envelope |
Both halves are guaranteed, so a redirect that fails writes an empty file rather than a truncated one that still parses.
# Refuse to deploy from an environment holding a secret that will not decrypt.
unreadable=$(prk secrets list --json | jq '[.[] | select(.unreadable)] | length')
[ "$unreadable" -eq 0 ] || { echo "$unreadable secret(s) will not decrypt" >&2; exit 1; }# Optimistic write: refuse if anyone else touched the environment in between.
rev=$(prk env list --json | jq -r '.[] | select(.slug == "production") | .rev')
prk secrets upload .env --expected-rev "$rev" --no-input# Diff two environments by key. Names only — no values are involved.
diff <(prk secrets list --json -E staging | jq -r '.[].key' | sort) \
<(prk secrets list --json -E production | jq -r '.[].key' | sort)Complete walkthroughs live in Examples; every command, flag and exit code is in the CLI reference.
🏛️ Design decisions worth knowing
🛡️ Access control
A grant is a role — what you may do — at a scope — where you may do it.
| Role | Read metadata | Read values | Write secrets | Manage grants |
| :------- | :-----------: | :---------: | :-----------: | :-----------: |
| reader | ✅ | ✅ | — | — |
| writer | ✅ | ✅ | ✅ | — |
| admin | ✅ | ✅ | ✅ | ✅ |
Totally ordered: reader < writer < admin.
| Scope | Written as | Covers |
| :------------ | :---------------- | :--------------------------------- |
| global | *:* (default) | Every project on the server |
| project | acme:* | Every environment in acme |
| environment | acme:production | One environment, and only that one |
Grants inherit downwards only: a project grant covers every environment in it, and an environment admin is not a project admin.
prk access grant [email protected] --role admin # everything
prk access grant [email protected] --role writer --scope acme:* # one project
prk access grant [email protected] --role reader --scope acme:production # one environment
prk access grant [email protected] --role reader --scope acme:staging --expires-in 30A group is a named set of identities, and holds grants of its own — so the same scopes are handed out to a roster rather than to a person. Membership on its own confers nothing.
[!NOTE] Your effective role at a scope is the maximum over your direct grants and the grants of every group you belong to: purely additive, with no deny rules, because a deny that silently overrides an explicit grant is the most confusing thing an authorization system can have.
It resolves in one query, so a 200-secret operation performs one authorization lookup rather than two hundred.
prk access explain — GET /identities/{id}/effective-permissions — answers "why does Bob have
production?" by naming the grant or group that conferred it. There is a
worked example above.
🚀 Getting started
Deploy it to your own account. This repository never deploys it for you.
git clone https://github.com/yashau/prick && cd prick
mise trust && mise run bootstrap
pnpm --filter @prick/app exec wrangler d1 create prick # paste the database_id into wrangler.jsonc
# set "routes" and the ACCESS_* vars in packages/app/wrangler.jsonc — see the Quickstart
openssl rand -base64 32 | pnpm --filter @prick/app exec wrangler secret put MASTER_KEY
pnpm --filter @prick/app exec wrangler d1 migrations apply prick --remote
pnpm --filter @prick/app exec wrangler deploy[!IMPORTANT] Put Cloudflare Access in front of the hostname before you put a secret in it. The Worker ships with
workers_devandpreview_urlsdisabled and CI asserts both on every push, because an unprotected hostname serves the entire application without a JWT.
Then sign in. The first sign-in creates a profile — a local name for the server and its session — so later commands need no URL:
prk login https://secrets.example.comSigning in to https://secrets.example.com as profile `default`
Signed in to https://secrets.example.com (profile default)A second server is a second profile, not a replacement:
prk profile add client-b https://secrets.client-b.example
prk login --profile client-b
prk --profile client-b secrets list --project api --env productionWith two or more profiles, a command that changes data must name its server; the default alone is refused. See Multiple servers.
Quickstart · Authentication · Access control · Threat model
🔑 MASTER_KEY is the whole ballgame
[!CAUTION] Lose it and every stored secret is unrecoverable. A D1 export without it is just ciphertext — which is a feature rather than a bug, and also the most common way people lose everything. Back up the key before you store the first secret.
Rotation is incremental, and both the settings screen and prk keyring status tell you when it is
safe to drop the retired key — counted live, every time you ask.
prk keyring rekey --until-doneSee Backup and recovery and Key rotation.
📊 Status
The CLI is published on crates.io, npm, Homebrew, Scoop and WinGet, and the Worker is yours to deploy. Releases are CalVer, cut from a git tag, and every archive carries a provenance attestation and an SBOM.
The test suite is real: six suites — Rust, Worker + UI, end-to-end, scripts, the GitHub Action and
the MCP server — and CI runs every one of them on every push. prick-core additionally carries a
machine-checked purity proof under miri, and cargo audit bin works on a downloaded binary because
the dependency list is embedded in it. mise run test prints the current counts, which is the only
place they stay right.
Migrations are additive only, the API is versioned at /api/v1, and a client and server on
different versions is supported.
🛠️ Development
The only thing you install is mise. It pins everything else.
mise run dev # Worker + UI
mise run test # every suite
mise run ci # a superset of CI — run before opening a PRSee CONTRIBUTING.md and AGENTS.md.
