Skip to content

Secrets

A Worker secret exists only inside a request. Your plumix.config.ts runs long before that, on your machine, during the build, so any slot that needs a secret takes a function of the env rather than the value itself.

EnvInput<T> is the type that says so. It is T | ((env: PlumixEnv) => T), and every slot carrying a credential accepts either form.

The literal form is for values you are willing to commit. The resolver form is for everything else. Plumix calls the resolver with the real Worker env, and env is the augmentable PlumixEnv, so the read is type-checked rather than a string lookup you hope spells the key right.

These slots take this shape today.

  • mailer takes a Mailer or a function returning one.
  • An OAuth provider’s credentials, through github(...) or google(...).
  • R2’s S3 credentials, as r2({ s3 }), and the s3() slot’s, as s3({ credentials }).
  • auth.passkey.origin and auth.passkey.allowedOrigins.
  • The libsql connection config, for a deploy not on D1.
  • The Turnstile site key and secret key on the demo preset, from the @plumix/runtime-cloudflare/demo subpath.

Resolution is memoized by the identity of the function you passed, in a WeakMap. A resolver runs once per Worker isolate and its result is reused for every later request, so a resolver that opens a transport builds one transport rather than one per request. Do not put per-request logic inside one.

Give the recipe site a real mailer. Magic-link sign-in emails a one-time URL rather than asking for a password, so it needs somewhere to post mail. A project scaffolded with that method gets mailer: consoleMailer(), which writes each message to the log for you to copy the link out of. That is a development stand-in rather than a fallback. auth.magicLink with no top-level mailer makes plumix() throw magic_link_requires_mailer while the config is built, so the slot is a hard requirement and not an upgrade.

  1. Declare the key on the env. Put this beside your config, and augment "plumix" rather than any other specifier.

    declare module "plumix" {
    interface PlumixEnv {
    readonly RESEND_API_KEY: string;
    }
    }
  2. Write the resolver. The function receives the typed env and returns the value the slot wants.

    import type { EnvInput, Mailer } from "plumix";
    export const resendMailer: EnvInput<Mailer> = (env) => ({
    async send(message) {
    const response = await fetch("https://api.resend.com/emails", {
    method: "POST",
    headers: {
    authorization: `Bearer ${env.RESEND_API_KEY}`,
    "content-type": "application/json",
    },
    body: JSON.stringify({
    from: "Recipes <hello@recipes.example>",
    to: message.to,
    subject: message.subject,
    text: message.text,
    }),
    });
    if (!response.ok) {
    throw new Error(`resend refused the send: ${String(response.status)}`);
    }
    },
    });

    Pass it as mailer: resendMailer in plumix.config.ts, in place of consoleMailer().

  3. Set the value locally. Create .dev.vars in the project root:

    # Local secrets for `plumix dev`. Never commit real values.
    RESEND_API_KEY=re_your_local_key
  4. Set it on the deploy.

    Terminal window
    wrangler secret put RESEND_API_KEY

    Wrangler prompts for the value and stores it against the Worker. It is never written back into your repository.

plumix.config.ts is evaluated by the CLI and by the build, in Node, on your machine. process.env there is your shell. A Worker has no process.env at all, and its secrets arrive as properties of the env argument the platform passes to fetch.

So a value read at config-evaluation time is baked into the bundle. That is right for a site name and wrong for an API key. The resolver defers the read to the moment the env exists, which is also what makes the same bundle correct on production and on a preview branch with different credentials.

.dev.vars sits in the project root, one KEY=value per line, and plumix dev loads it into the Worker env. The scaffolder writes the file when your selected auth methods need one, seeds it with the key names and no values, and lists it in .gitignore.

Editing the file restarts the dev server for you. @cloudflare/vite-plugin watches .dev.vars alongside the wrangler config and calls Vite’s own restart when it changes. It watches for a change to a file that exists, so the one time you have to restart by hand is right after creating .dev.vars.

wrangler secret put NAME is the usual route in, one prompt per name. wrangler secret bulk takes a file of them at once, wrangler deploy --secrets-file <path> ships them with the deploy itself, and the Cloudflare dashboard sets them by hand. The one place a secret must never go is the wrangler vars block, because that block is part of the config file and gets committed.

Wrangler can hold you to that. Listing a name under secrets.required makes wrangler deploy fail when the secret is not configured, which turns a silently broken feature into a failed deploy.

{
"secrets": {
"required": ["RESEND_API_KEY"],
},
}

The first deploy of a brand-new Worker is the exception to the ordering. A Worker that does not exist yet cannot have a secret set on it in advance, so wrangler deploy refuses and points you at wrangler deploy --secrets-file <path>. Ship the first deploy that way, or leave the name off secrets.required until the Worker exists and set it with wrangler secret put afterwards.

Bindings and secrets share one namespace on env, so a secret cannot take the name of a D1 or R2 binding. Bindings and Environment has the full list of names the Cloudflare adapters look for.

Some adapters take their credentials from the env directly, with no resolver in your config at all. r2() reads CF_ACCOUNT_ID, R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY and <BINDING>_BUCKET when you have not passed an s3 block.

These fail soft: an incomplete R2 credential set leaves presigned uploads off, and a CDN whose zoneId or purgeToken resolves to nothing leaves the CDN off — which on a deploy that was caching is worth planning for, since the pages the CDN already holds stay stale for their whole freshness window and no purge reaches them. CDN Caching has the rotation order that avoids it. You get a working site with one capability absent rather than a 500, which is convenient and easy to miss. Check the capability, not the deploy log.

The same shape, with two keys instead of one. github takes either literal credentials or the resolver.

import { auth, github } from "plumix";
declare module "plumix" {
interface PlumixEnv {
readonly GITHUB_CLIENT_ID: string;
readonly GITHUB_CLIENT_SECRET: string;
}
}
export const recipeAuth = auth({
passkey: {
rpName: "Recipes",
rpId: "recipes.example",
origin: "https://recipes.example",
},
oauth: {
providers: {
github: github((env) => ({
clientId: env.GITHUB_CLIENT_ID,
clientSecret: env.GITHUB_CLIENT_SECRET,
})),
},
},
});

The client secret is used at token exchange, inside a request, which is exactly why it cannot be a literal read from your shell at build time. The map key github doubles as the URL segment for that provider’s sign-in route and as the stored provider name on the account row, so renaming it later breaks existing links between users and their accounts.

passkey.origin takes a resolver for the same reason, though the value is not secret. It changes per deploy.

import { auth } from "plumix";
declare module "plumix" {
interface PlumixEnv {
readonly PUBLIC_ORIGIN: string;
}
}
export const recipeAuth = auth({
passkey: {
rpName: "Recipes",
rpId: "recipes.example",
origin: (env) => env.PUBLIC_ORIGIN,
},
});

rpId deliberately takes no resolver. It anchors the credential, and a passkey enrolled under one rpId will not verify under another, so it has to be the same string in every environment. On Workers Builds, prefer cloudflareDeployOrigin over hand-writing this. It derives all three origin fields from the build env at once. Cloudflare Workers covers it.

Bindings and Environment is the other half of what lands on env, and the two share a namespace. Passkeys covers what the origin fields above are protecting. The Access & Identity describes the principal a signed-in request resolves to, which is what a mailer and an OAuth provider are configured to produce.

If you have not wired the adapters yet, Cloudflare Workers is where each slot in this page’s examples is documented. Configuration is the reference for every config slot, including the ones that take an EnvInput. Overview puts the build, the migrations and the secrets in the order you run them.