Skip to content

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_stdout and print_stderr workspace-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.path and issue.message and drops issue.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_dev and preview_urls are set to false, 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 that prk login and prk doctor both fail loudly if /health answers 200 unauthenticated.
  • 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_LIMITED code 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. getKeyringStatus and rekeyPage are implemented, and a rotation moves one page per call to POST /admin/rekey. MASTER_KEY_OLD becomes 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_SECRETS is 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.

Next steps