Skip to content

Secrets Store & auth tokens

Cloudflare’s Secrets Store is an account-level container for secrets. Unlike a plain env var, a stored secret is shared across Workers, redacted in the dashboard, and read live at runtime — bound Workers see the current value without a redeploy.

In this guide you’ll build a bearer-token-protected Worker: a Store, a token minted with Alchemy.Random, a Secret holding it, and a ReadSecret binding that checks it on every request.

src/auth.ts
import * as Cloudflare from "alchemy/Cloudflare";
export const Store = Cloudflare.SecretsStore.Store("AuthSecrets");

Cloudflare allows one Secrets Store per account, so the provider adopts the existing store if one exists (creating it only on a truly fresh account) and never deletes it on alchemy destroy — it’s account-level infrastructure that outlives any single stack.

src/auth.ts
import { Random } from "alchemy";
export const AuthTokenValue = Random("AuthTokenValue");

Random mints a random value once (32 bytes, hex-encoded — pass bytes to change the length) and persists it in state, so the token stays the same on every subsequent deploy. Its text attribute is a Redacted<string>, so it never leaks into logs.

src/auth.ts
import * as Effect from "effect/Effect";
export const AuthToken = Effect.gen(function* () {
const store = yield* Store;
const value = yield* AuthTokenValue;
return yield* Cloudflare.SecretsStore.Secret("AuthToken", {
store,
value: value.text,
});
});

Secret writes the value into the store under the resource’s logical ID (pass name to override). The value is any Redacted<string>Redacted.make(process.env.API_KEY!) works just as well as a Random output. Changing the value updates the secret in place; renaming it or moving it to another store replaces it.

Bind the secret in the Worker’s init phase with ReadSecret, then check the Authorization header in fetch:

src/api.ts
import * as Cloudflare from "alchemy/Cloudflare";
import * as Effect from "effect/Effect";
import * as Redacted from "effect/Redacted";
import { HttpServerRequest } from "effect/unstable/http/HttpServerRequest";
import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse";
import { AuthToken } from "./auth.ts";
export default class Api extends Cloudflare.Worker<Api>()(
"Api",
{
main: import.meta.url,
},
Effect.gen(function* () {
const authToken = yield* Cloudflare.SecretsStore.ReadSecret(AuthToken);
return {
fetch: Effect.gen(function* () {
const request = yield* HttpServerRequest;
if (request.url.startsWith("/protected")) {
const authHeader = request.headers.authorization;
const expected = yield* authToken;
if (
!authHeader ||
authHeader !== `Bearer ${Redacted.value(expected)}`
) {
return HttpServerResponse.text("unauthorized", { status: 401 });
}
return HttpServerResponse.text("ok");
}
return HttpServerResponse.text("public");
}).pipe(
Effect.catchTag("SecretError", (err) =>
Effect.succeed(
HttpServerResponse.text(`failed to read secret: ${err.message}`, {
status: 500,
}),
),
),
),
};
}).pipe(Effect.provide(Cloudflare.SecretsStore.ReadSecretBinding)),
) {}

The client returned by ReadSecret is itself an Effect that resolves to the secret’s current value, so yield* authToken reads it — .get() does the same thing as a callable, and .raw exposes the underlying Cloudflare SecretsStoreSecret binding. Reads can fail with a typed SecretError, handled here with Effect.catchTag.

alchemy.run.ts
import * as Alchemy from "alchemy";
import * as Cloudflare from "alchemy/Cloudflare";
import * as Output from "alchemy/Output";
import * as Effect from "effect/Effect";
import * as Redacted from "effect/Redacted";
import Api from "./src/api.ts";
import { AuthTokenValue } from "./src/auth.ts";
export default Alchemy.Stack(
"WorkerAuth",
{
providers: Cloudflare.providers(),
state: Cloudflare.state(),
},
Effect.gen(function* () {
const authToken = yield* AuthTokenValue;
const api = yield* Api;
return {
url: api.url.as<string>(),
authToken: authToken.text.pipe(Output.map(Redacted.value)),
};
}),
);

authToken.text is an Output<Redacted<string>>. Mapping over the Output with Redacted.value unwraps it so the stack output emits the real token — otherwise it JSON-serializes to the literal string "<redacted>".

Terminal window
alchemy deploy
Terminal window
curl https://<your-worker>.workers.dev/protected
# → unauthorized (401)
curl -H "Authorization: Bearer <authToken from the deploy output>" \
https://<your-worker>.workers.dev/protected
# → ok

Async (non-Effect) Workers don’t have an init phase to yield* a binding into. Declare the Secret on the Worker’s env instead — the provider maps it to a native secrets_store_secret binding, so the runtime sees a real SecretsStoreSecret with a .get() method:

import type { SecretsStoreSecret } from "@cloudflare/workers-types";
export default class Api extends Cloudflare.Worker<Api>()(
"Api",
{
main: import.meta.url,
env: {
AUTH_TOKEN: AuthToken,
},
},
Effect.gen(function* () {
return {
fetch: Effect.gen(function* () {
const env = yield* Cloudflare.Workers.WorkerEnvironment;
const secret = (env as Record<string, SecretsStoreSecret>).AUTH_TOKEN;
const value = yield* Effect.promise(() => secret.get());
// ...
}),
};
}),
) {}

Both end up as bindings on the Worker — pick based on where the value lives and who shares it:

  • Env vars (Config.redacted) — the value comes from your environment (.env, CI secrets) at deploy time and is baked into that one Worker. Right for third-party API keys and per-app config. See Secrets & env for the step-by-step.
  • Secrets Store — the value lives in Cloudflare’s account-level store. One secret can be bound into many Workers, reads happen at runtime so a rotation propagates without redeploying every consumer, and the dashboard redacts it. Right for shared credentials and tokens your infrastructure owns — like the bearer token in this guide.
  • Workers — the host the secret binds into.
  • Secrets and Config — how env-var bindings work under the hood.
  • Secrets & env — the category anchor: Config, Random, and when to use the store.

Reference: