| 1 | --- |
| 2 | title: Secrets Store & auth tokens |
| 3 | description: Store secrets in Cloudflare's account-level Secrets Store, generate stable tokens with Alchemy.Random, and read them in a Worker through the ReadSecret binding. |
| 4 | sidebar: |
| 5 | order: 18 |
| 6 | --- |
| 7 | |
| 8 | Cloudflare's Secrets Store is an account-level container for secrets. |
| 9 | Unlike a plain env var, a stored secret is shared across Workers, |
| 10 | redacted in the dashboard, and read live at runtime — bound Workers |
| 11 | see the current value without a redeploy. |
| 12 | |
| 13 | In this guide you'll build a bearer-token-protected Worker: a |
| 14 | `Store`, a token minted with `Alchemy.Random`, a `Secret` holding it, |
| 15 | and a `ReadSecret` binding that checks it on every request. |
| 16 | |
| 17 | ## Create the store |
| 18 | |
| 19 | ```typescript |
| 20 | // src/auth.ts |
| 21 | import * as Cloudflare from "alchemy/Cloudflare"; |
| 22 | |
| 23 | export const Store = Cloudflare.SecretsStore.Store("AuthSecrets"); |
| 24 | ``` |
| 25 | |
| 26 | Cloudflare allows **one Secrets Store per account**, so the provider |
| 27 | adopts the existing store if one exists (creating it only on a truly |
| 28 | fresh account) and never deletes it on `alchemy destroy` — it's |
| 29 | account-level infrastructure that outlives any single stack. |
| 30 | |
| 31 | ## Generate a stable token |
| 32 | |
| 33 | ```typescript |
| 34 | // src/auth.ts |
| 35 | import { Random } from "alchemy"; |
| 36 | |
| 37 | export const AuthTokenValue = Random("AuthTokenValue"); |
| 38 | ``` |
| 39 | |
| 40 | `Random` mints a random value once (32 bytes, hex-encoded — pass |
| 41 | `bytes` to change the length) and persists it in state, so the token |
| 42 | stays the same on every subsequent deploy. Its `text` attribute is a |
| 43 | `Redacted<string>`, so it never leaks into logs. |
| 44 | |
| 45 | ## Put it in the store |
| 46 | |
| 47 | ```typescript |
| 48 | // src/auth.ts |
| 49 | import * as Effect from "effect/Effect"; |
| 50 | |
| 51 | export const AuthToken = Effect.gen(function* () { |
| 52 | const store = yield* Store; |
| 53 | const value = yield* AuthTokenValue; |
| 54 | return yield* Cloudflare.SecretsStore.Secret("AuthToken", { |
| 55 | store, |
| 56 | value: value.text, |
| 57 | }); |
| 58 | }); |
| 59 | ``` |
| 60 | |
| 61 | `Secret` writes the value into the store under the resource's logical |
| 62 | ID (pass `name` to override). The value is any `Redacted<string>` — |
| 63 | `Redacted.make(process.env.API_KEY!)` works just as well as a |
| 64 | `Random` output. Changing the value updates the secret in place; |
| 65 | renaming it or moving it to another store replaces it. |
| 66 | |
| 67 | ## Read it in a Worker |
| 68 | |
| 69 | Bind the secret in the Worker's Construction phase with `ReadSecret`, then |
| 70 | check the `Authorization` header in `fetch`: |
| 71 | |
| 72 | ```typescript |
| 73 | // src/api.ts |
| 74 | import * as Cloudflare from "alchemy/Cloudflare"; |
| 75 | import * as Effect from "effect/Effect"; |
| 76 | import * as Redacted from "effect/Redacted"; |
| 77 | import { HttpServerRequest } from "effect/http/HttpServerRequest"; |
| 78 | import * as HttpServerResponse from "effect/http/HttpServerResponse"; |
| 79 | import { AuthToken } from "./auth.ts"; |
| 80 | |
| 81 | export default class Api extends Cloudflare.Worker<Api>()( |
| 82 | "Api", |
| 83 | { |
| 84 | main: import.meta.url, |
| 85 | }, |
| 86 | Effect.gen(function* () { |
| 87 | const authToken = yield* Cloudflare.SecretsStore.ReadSecret(AuthToken); |
| 88 | |
| 89 | return { |
| 90 | fetch: Effect.gen(function* () { |
| 91 | const request = yield* HttpServerRequest; |
| 92 | |
| 93 | if (request.url.startsWith("/protected")) { |
| 94 | const authHeader = request.headers.authorization; |
| 95 | const expected = yield* authToken; |
| 96 | if ( |
| 97 | !authHeader || |
| 98 | authHeader !== `Bearer ${Redacted.value(expected)}` |
| 99 | ) { |
| 100 | return HttpServerResponse.text("unauthorized", { status: 401 }); |
| 101 | } |
| 102 | return HttpServerResponse.text("ok"); |
| 103 | } |
| 104 | |
| 105 | return HttpServerResponse.text("public"); |
| 106 | }).pipe( |
| 107 | Effect.catchTag("SecretError", (err) => |
| 108 | Effect.succeed( |
| 109 | HttpServerResponse.text(`failed to read secret: ${err.message}`, { |
| 110 | status: 500, |
| 111 | }), |
| 112 | ), |
| 113 | ), |
| 114 | ), |
| 115 | }; |
| 116 | }).pipe(Effect.provide(Cloudflare.SecretsStore.ReadSecretBinding)), |
| 117 | ) {} |
| 118 | ``` |
| 119 | |
| 120 | The client returned by `ReadSecret` is itself an Effect that resolves |
| 121 | to the secret's current value, so `yield* authToken` reads it — |
| 122 | `.get()` does the same thing as a callable, and `.raw` exposes the |
| 123 | underlying Cloudflare `SecretsStoreSecret` binding. Reads can fail |
| 124 | with a typed `SecretError`, handled here with `Effect.catchTag`. |
| 125 | |
| 126 | :::caution |
| 127 | The value is a `Redacted<string>` — you MUST unwrap it with |
| 128 | `Redacted.value(expected)` before comparing. Coercing a `Redacted` |
| 129 | into a template literal yields the literal string `"<redacted>"`, so |
| 130 | `` `Bearer ${expected}` `` would compare against `"Bearer <redacted>"` |
| 131 | and silently reject every real token. |
| 132 | ::: |
| 133 | |
| 134 | ## Add to the stack |
| 135 | |
| 136 | ```typescript |
| 137 | // alchemy.run.ts |
| 138 | import * as Alchemy from "alchemy"; |
| 139 | import * as Cloudflare from "alchemy/Cloudflare"; |
| 140 | import * as Output from "alchemy/Output"; |
| 141 | import * as Effect from "effect/Effect"; |
| 142 | import * as Redacted from "effect/Redacted"; |
| 143 | import Api from "./src/api.ts"; |
| 144 | import { AuthTokenValue } from "./src/auth.ts"; |
| 145 | |
| 146 | export default Alchemy.Stack( |
| 147 | "WorkerAuth", |
| 148 | { |
| 149 | providers: Cloudflare.providers(), |
| 150 | state: Cloudflare.state(), |
| 151 | }, |
| 152 | Effect.gen(function* () { |
| 153 | const authToken = yield* AuthTokenValue; |
| 154 | const api = yield* Api; |
| 155 | |
| 156 | return { |
| 157 | url: api.url.as<string>(), |
| 158 | authToken: authToken.text.pipe(Output.map(Redacted.value)), |
| 159 | }; |
| 160 | }), |
| 161 | ); |
| 162 | ``` |
| 163 | |
| 164 | `authToken.text` is an `Output<Redacted<string>>`. Mapping over the |
| 165 | Output with `Redacted.value` unwraps it so the stack output emits the |
| 166 | real token — otherwise it JSON-serializes to the literal string |
| 167 | `"<redacted>"`. |
| 168 | |
| 169 | ## Deploy and test |
| 170 | |
| 171 | ```sh |
| 172 | alchemy deploy |
| 173 | ``` |
| 174 | |
| 175 | ```sh |
| 176 | curl https://<your-worker>.workers.dev/protected |
| 177 | # → unauthorized (401) |
| 178 | |
| 179 | curl -H "Authorization: Bearer <authToken from the deploy output>" \ |
| 180 | https://<your-worker>.workers.dev/protected |
| 181 | # → ok |
| 182 | ``` |
| 183 | |
| 184 | ## Async Workers: bind via `env` |
| 185 | |
| 186 | Async (non-Effect) Workers don't have a Construction phase to `yield*` a |
| 187 | binding into. Declare the Secret on the Worker's `env` instead — the |
| 188 | provider maps it to a native `secrets_store_secret` binding, so the |
| 189 | runtime sees a real `SecretsStoreSecret` with a `.get()` method: |
| 190 | |
| 191 | ```typescript |
| 192 | import type { SecretsStoreSecret } from "@cloudflare/workers-types"; |
| 193 | |
| 194 | export default class Api extends Cloudflare.Worker<Api>()( |
| 195 | "Api", |
| 196 | { |
| 197 | main: import.meta.url, |
| 198 | env: { |
| 199 | AUTH_TOKEN: AuthToken, |
| 200 | }, |
| 201 | }, |
| 202 | Effect.gen(function* () { |
| 203 | return { |
| 204 | fetch: Effect.gen(function* () { |
| 205 | const env = yield* Cloudflare.Workers.WorkerEnvironment; |
| 206 | const secret = (env as Record<string, SecretsStoreSecret>).AUTH_TOKEN; |
| 207 | const value = yield* Effect.promise(() => secret.get()); |
| 208 | // ... |
| 209 | }), |
| 210 | }; |
| 211 | }), |
| 212 | ) {} |
| 213 | ``` |
| 214 | |
| 215 | ## Env vars vs Secrets Store |
| 216 | |
| 217 | Both end up as bindings on the Worker — pick based on where the value |
| 218 | lives and who shares it: |
| 219 | |
| 220 | - **Env vars** ([`Config.Redacted`](/environments/secrets)) — the value |
| 221 | comes from *your* environment (`.env`, CI secrets) at deploy time |
| 222 | and is baked into that one Worker. Right for third-party API keys |
| 223 | and per-app config. See [Secrets & env](/cloudflare/security/secrets-env) |
| 224 | for the step-by-step. |
| 225 | - **Secrets Store** — the value lives in *Cloudflare's* account-level |
| 226 | store. One secret can be bound into many Workers, reads happen at |
| 227 | runtime so a rotation propagates without redeploying every |
| 228 | consumer, and the dashboard redacts it. Right for shared |
| 229 | credentials and tokens your infrastructure owns — like the bearer |
| 230 | token in this guide. |
| 231 | |
| 232 | ## Where next |
| 233 | |
| 234 | - [Workers](/cloudflare/compute/workers) — the host the secret binds into. |
| 235 | - [Secrets and Config](/environments/secrets) — how env-var bindings |
| 236 | work under the hood. |
| 237 | - [Secrets & env](/cloudflare/security/secrets-env) — the category anchor: |
| 238 | Config, Random, and when to use the store. |
| 239 | |
| 240 | Reference: |
| 241 | |
| 242 | - [Store API reference](/providers/cloudflare/secretsstore/store) |
| 243 | - [Secret API reference](/providers/cloudflare/secretsstore/secret) |
| 244 | - [ReadSecret API reference](/providers/cloudflare/secretsstore/readsecret) |