Threat model
This page is written to be useful rather than reassuring. If a property does not hold, it is listed here rather than left for you to discover.
What is being protected
Secret values. Nothing else on the following list is encrypted:
- Secret key names.
- Project and environment names, slugs and descriptions.
- Identity subjects: email addresses and service token common names.
- Group slugs, names and memberships.
- Grants, and the entire audit log.
“Not encrypted” is not the same as “readable by anyone who asks”. Every one of
those is still behind the grant table at request time: listing identities, groups
and grants needs an admin grant at some scope, and reading the audit log is
narrowed to the scopes you administer. What the list means is that anyone holding
the database — a D1 export, a Cloudflare account compromise — reads all of it
in the clear, MASTER_KEY or no MASTER_KEY.
Who is trusted
| Party | Trusted with |
|---|---|
Anyone who can wrangler deploy |
Everything. See below |
| Cloudflare | Running the Worker and D1, and authenticating at the edge |
| A global administrator | Every value in the system |
| The prick server | Storing and returning values. Not with choosing what code runs on your machine |
The three things you should know before adopting this
1. Deploy access is total access
Anyone who can run wrangler deploy against your account can read MASTER_KEY
— by deploying a Worker that prints it, or simply by replacing prick with
something that exfiltrates every value it decrypts. There is no grant, no role
and no audit configuration that changes this.
That is not a flaw in the authorization model; it is the boundary the authorization model sits inside. It is also why the bootstrap admin mechanism is a plain configuration variable rather than a token: anchoring the first administrator to the authority that already has total access adds no exposure, and a printed one-time credential would.
Practical consequence: the set of people who can deploy to this Cloudflare account is the set of people who can read every secret. Treat Cloudflare account access as equivalent to a global admin grant, and audit it in the same way.
2. prk run injects through the environment
prk run puts values into the child process’s environment. On Linux that
environment is readable at /proc/<pid>/environ by the same user and by
root, appears in a core dump, and may be captured by process monitoring or a
crash reporter. On other platforms the equivalents differ in detail, not in kind.
This is a deliberate trade. The alternatives are worse for the same threat: a
file on disk persists after the process exits, command-line arguments are visible
in ps to every user, and a private channel would require the child program to
cooperate. The environment is the mechanism that every program already
understands.
Practical consequence: prk run protects a secret from the disk and from
other users’ processes at rest, not from another process running as you, and not
from root. If your threat model includes a hostile process running as the same
user, prk run does not address it.
3. Secret key names are stored in plaintext
Only values are encrypted. DATABASE_URL is stored as text.
This is deliberate, and the reason is queryability: the UI lists key names, the
diff view compares them, the audit log names them, and an operator has to be able
to see that DATABASE_URL exists — and that it is missing from staging — without
decrypting anything. Encrypting names would mean either decrypting the whole
environment to render a list, or maintaining a searchable index that leaks the
same information by another route.
Practical consequence: anyone with read access to the D1 database learns your complete inventory of secret names, environments and people, even though the values are safe. Do not encode sensitive information in a key name.
What the design does protect against
Database read access
An attacker with a D1 export, or with read access to the database, gets
ciphertext. Without MASTER_KEY the values are not recoverable. This is also why
a backup without the key is worthless — see
Backup and recovery.
They do get all the metadata listed above.
Database write access
The additional authenticated data binds each ciphertext to (purpose, environment_id, key, version). An attacker who can write to D1 therefore cannot:
- Move a production ciphertext into a development environment they can read.
- Move a value from one key name to another.
- Replay an old version as the current one.
Each of those fails GCM tag verification. See Encryption.
They can still destroy data, and they can corrupt rows. Corruption is loud:
a decrypt failure fails the request or marks the row unreadable, and audits with
outcome: 'error'. Nothing skips a row it cannot read.
They can also edit the audit log. It is append-only by application convention, not by storage guarantee.
A malicious or compromised server
The server stores secrets. It does not get to choose what code runs on your machine.
prk run refuses to inject variables the dynamic loader or a language runtime
interprets before the program starts — LD_PRELOAD, DYLD_*, PATH,
NODE_OPTIONS, BASH_ENV and their relatives. Without that, a compromised
server achieves arbitrary code execution on every machine that runs prk run.
The refusal fails the whole launch rather than dropping the offending variable,
and --allow-unsafe-env is the deliberate opt-out.
Secrets leaking into logs and error messages
- The CLI denies
print_stdoutandprint_stderrworkspace-wide; exactly one module lifts the ban. A secret reaching stderr is a build failure, not a review outcome. - Secret values are held in types whose debug formatting is redacted.
- The API’s validation error formatter reads
issue.pathandissue.messageand dropsissue.input. A rejected secret write would otherwise put the plaintext in the response, the Worker log and the audit detail at once. - Crypto errors name the row — environment, key, version, key id — and never the value or the ciphertext.
- An unclassified 500 carries a constant message rather than the underlying one, because nothing has established what that text contains.
Secrets leaking into a browser page
Screens that display values are client-rendered only. There is no server render, so there is no serialised page payload for a value to sit in. Form actions never return values, because SvelteKit serialises an action’s return into page data.
Beyond that: a strict Content Security Policy with no host allowlist for scripts,
frame-ancestors 'none' delivered as a real header (meta-tag CSP ignores that
directive), no service worker registered anywhere — a service worker cache is a
plaintext secret store on disk — revealed values held only in memory with a
30-second expiry and an idle wipe, and secret inputs marked so password managers
do not capture and sync them.
Cross-site reads of the API
There is no CORS middleware, deliberately. Omitting Access-Control-Allow-Origin
entirely is what stops any other site reading a response from this API in a
victim’s browser.
Token forgery
The Worker verifies the Access JWT itself rather than trusting that the edge ran.
The signing algorithm is taken from the JWKS entry matched by kid, never from
the token header, which rejects alg: none and RS256→HS256 confusion. Issuer is
exact-match, audience is checked against the array, and expiry has no skew
allowance.
What it does not protect against
- A malicious administrator. An admin can read everything they are granted. The audit log records that they did, which is detection, not prevention.
- Cloudflare. The Worker decrypts values in Cloudflare’s runtime. If Cloudflare is in your threat model, this architecture is not for you.
- A misconfigured Access application. If Access is not attached to the
hostname, the entire authorization model is bypassed — every project, every
environment, every reveal endpoint, open to the internet.
workers_devandpreview_urlsare set tofalse, and CI asserts both on every push. An Access application with an over-broad policy is not something prick can detect at all. Verify it yourself, as in the Quickstart, and note thatprk loginandprk doctorboth fail loudly if/healthanswers200unauthenticated. - A compromised developer machine. The token file is mode
0600, which stops other users, not malware running as you. - Denial of service. There are size and count limits, but no rate limiting is
implemented in the Worker. The
RATE_LIMITEDcode exists in the taxonomy for a response Cloudflare’s own protections may produce, not because prick enforces a quota. - Multi-tenancy. This is a single-tenant, self-hosted system. Projects are organisation, not isolation: the same master key protects every value in the deployment.
- Loss of
MASTER_KEY. There is no escrow, no recovery key and no vendor who can help. This is by construction.
What the operator carries
The protections above are exercised on live paths: the API surface is mounted,
the CLI authenticates and injects, and the reveal audit, the no-store headers
and the loud decrypt failure all run on real requests. Three of them depend on an
operator to carry through:
- The rekey advances while you drive it.
getKeyringStatusandrekeyPageare implemented, and a rotation moves one page per call toPOST /admin/rekey.MASTER_KEY_OLDbecomes removable once the ring reports zero rows against every retired key id, so drive a rotation through to that point. See Key rotation. ENV_MAX_SECRETSis derived rather than measured. 500 comes from an undocumented per-batch statement limit weighed against a documented 30-second ceiling. Load-test it against your own workload and lower the cap if the measurement disagrees.- Provenance is established at publish time. npm provenance and trusted publishing are properties the release workflow confers on each release it runs. See Releasing.
Do not run this as a production secrets manager yet.
Reporting a vulnerability
Do not open a public issue. Use a private security advisory — see
.github/SECURITY.md in the repository.