Secrets
HS-X today owns one piece of the secrets story end-to-end: the HubSpot OAuth tokens your runtime needs to call HubSpot APIs. Everything else (third-party API keys, signing secrets, database URLs) you set on the Cloudflare Worker yourself with wrangler. This guide draws the line clearly, walks each surface, and ends with the design preview for the unified hs-x secrets CLI that is on the roadmap but not yet shipped.
TL;DR — HS-X fully manages one secret class end-to-end: the HubSpot OAuth tokens your runtime uses, sealed in your own Cloudflare KV. Everything else (third-party API keys, signing secrets) you set on the Worker with wrangler secret put today. This guide draws that line and walks both paths.
Before you begin
The fastest way to stay sane about secrets in an HS-X project is to separate them into three categories and learn which tool owns each one. The categories do not overlap, the tools do not compete, and the only confusion comes from treating them as one undifferentiated bucket called “secrets”. The three have different lifetimes, different rotation models, and different blast radii when they leak.
The three categories:
- HubSpot OAuth tokens — the access and refresh tokens your Worker uses to call HubSpot’s API on behalf of an installed portal.
- Cloudflare Worker runtime secrets — the third-party API keys, signing secrets, and encryption keys your handler code reaches for at runtime.
- Local-dev values — the same kinds of values as the second bucket, resolved against your laptop while you run
hs-x devinstead of against the deployed Worker.
HS-X manages the first category for you, end-to-end. The runtime obtains the OAuth token through the install flow, stores it encrypted in your tenant’s Cloudflare KV, refreshes it on demand through a four-lane state machine (more on that below), and hands the live accessToken to your handler as context.hubspot. You do not paste, rotate, or audit HubSpot tokens by hand under normal operation.
The second category, your own runtime secrets, is not yet wrapped by an HS-X CLI surface. You set those with wrangler secret put, and they appear on your handler as context.env.FOO. A unified hs-x secrets <name> --env <env> surface does not exist yet; the only hs-x secrets subcommand that ships today is hs-x secrets hubspot-oauth set (alias hs-x secret), which stores your app’s OAuth client credential in the control plane for the next deploy to install, and is not a general secrets surface.
The third category, local-dev values, uses the .dev.vars file, the same dotenv-format convention Wrangler established. hs-x dev runs your handlers in-process through the production router and reads .dev.vars from the project root on every invocation, so the same env.EMAILCHECK_API_KEY read works on your laptop and on the deployed Worker.
What owns what today
| Category | Who sets it | Who reads it at runtime | How it rotates |
|---|---|---|---|
| HubSpot OAuth tokens (per install) | HS-X, via the install OAuth flow | context.hubspot in your handler | HS-X runtime, on demand (token-service lanes) |
| Cloudflare Worker secrets | You, via wrangler secret put | context.env.FOO in your handler | You, by setting again and redeploying |
| Local-dev values | You, in .dev.vars | context.env.FOO during hs-x dev | You, by editing the file |
That table is the mental model. The rest of this guide walks each row in turn, ending with a design preview of the unified surface and a field guide to the common failures.
Let HS-X manage your HubSpot OAuth tokens
Of the three categories, this is the one where HS-X earns its keep. HubSpot OAuth access tokens are short-lived (30 minutes), and the refresh tokens that mint new access tokens are long-lived but rotate on each refresh. A handwritten implementation has to track expiry, serialize concurrent refresh attempts so two parallel requests do not race the refresh endpoint, persist the new refresh token immediately (HubSpot invalidates the old one), and recover when the refresh token itself has been revoked. HS-X’s runtime does all of that for you.
Every install has a token blob in KV with four timestamps that classify the token into one of four lanes: fresh (still well inside the access window), refresh-ahead (past the proactive refresh threshold but still valid), soft-expired (at or just past expiry, refresh required), and hard-expired (long past expiry). The classifier picks the lane based on the current time and the timestamps in the blob; fresh and refresh-ahead are served from cache, the two expired lanes trigger a refresh under a per-install lock so concurrent handlers do not all hit HubSpot at once.
When the refresh succeeds, the new access token, the new refresh token, and the updated expiry timestamps are persisted atomically, and the install state stays active. When the refresh fails, the outcome depends on why. A definitive revocation (HubSpot’s BAD_REFRESH_TOKEN, or the OAuth invalid_grant error) means the portal uninstalled the app or withdrew the grant, so the install flips to uninstalled. A transient failure (network, a 5xx, a rate limit) flips it to reauth_required instead, and never marks an uninstall. The handler that hit the failure gets a TOKEN_REFRESH_FAILED error; every later call for that install is refused up front with TOKEN_STATE_NOT_ACTIVE (“Install <id> is uninstalled; HubSpot token use is blocked.”) until the state returns to active.
What you actually do as a developer
You wire the OAuth client once at project setup, and after that the runtime handles every refresh. The wiring is two commands:
hs-x connect hubspot --account-id <id> --developer-account-id <id> --display-name "My App"
hs-x secrets hubspot-oauth set --account-id <id> --project-id <id> \
--hubspot-app-id <id> --client-id <id> --client-secret <secret> --env productionThe first command links your HS-X account to a HubSpot developer account and stores a personal access key (PAK) so the CLI can call HubSpot’s developer-side endpoints (project upload, deploy, account introspection). The PAK is sourced from --pak, from $HSX_HUBSPOT_PAK, or auto-discovered from your HubSpot CLI config if you have already run hs accounts auth. The PAK is for your local CLI; it never reaches the deployed Worker.
The second command stores your HubSpot OAuth app’s client_id and client_secret in the HS-X control plane, scoped to (account, project, environment, app); --env production|staging|dev picks the slot (default production, or HSX_ENVIRONMENT). Nothing reaches Cloudflare when you run it. The next hs-x deploy for that environment reads the scoped secret and installs it on the Worker as the HSX_HUBSPOT_CLIENT_ID var and the HSX_HUBSPOT_CLIENT_SECRET Worker secret; until that deploy runs, the install callback and every HubSpot-signed route stay unavailable (503 hubspot_signature_verification_unavailable). In a terminal the command shows where the value is going and asks for confirmation; non-TTY and --json runs must supply every value (flags or HSX_HUBSPOT_APP_ID, HSX_HUBSPOT_CLIENT_ID, HSX_HUBSPOT_CLIENT_SECRET) and pass --yes. Unlinked projects skip this command entirely and pass --hubspot-client-id / --hubspot-client-secret (or the same two environment variables) to hs-x deploy instead. These are the credentials the install OAuth flow uses to exchange an authorization code for a token pair when a merchant clicks “Install” on your app’s listing.
What HS-X puts on your Worker
A deploy installs a handful of secrets and vars of its own, beside whatever you add with wrangler. Knowing their names keeps you from colliding with them, and from wondering where they came from in wrangler secret list:
| Binding | Kind | When | Purpose |
|---|---|---|---|
HSX_HUBSPOT_CLIENT_SECRET | secret | Once the OAuth client secret is known | Install OAuth exchange and HubSpot v3 signature verification |
HSX_TOKEN_KEY | secret | Once an app id is known | Seals the per-install token blobs in INSTALL_KV |
HSX_SYNC_GRANT_KEY | secret | Linked deploys | Verifies the signed grants behind sync runs, flag authoring, and tenant reads |
HSX_MACHINE_SECRET | secret | Unlinked deploys with the heartbeat on | Signs the anonymous heartbeat |
| billing runtime token | secret | Linked deploys with a billing catalog | Lets ctx.billing talk to the control plane |
HSX_HUBSPOT_CLIENT_ID, HSX_APP_ID, HSX_HUBSPOT_SCOPES | var | Once known | Install OAuth start and callback |
HSX_CONTROL_PLANE_URL, HSX_TAGGED_RESOURCE_COUNT | var | Linked deploys | Attestation and drift |
Reading the token from a handler
Once an install completes, your handler reaches the live access token through context.hubspot. The runtime resolves the token through the lane state machine on every invocation, so a handler that runs at minute 29 of an access token’s lifetime gets the still-valid token from cache, while a handler that runs at minute 31 transparently refreshes first and gets the new one. Your code looks the same in both cases.
// src/workers/email-guard.ts — the check-email-health tool, abridged from the agent-tools guide
import { defineWorker, ok } from '@hs-x/sdk';
const worker = defineWorker('email-guard');
worker.tool('check-email-health', {
label: 'Check email health',
objectType: 'contacts',
input: { contactId: { type: 'string', label: 'Contact id', required: true } },
output: { summary: { type: 'string' } },
// hubspot is the runtime-resolved HubSpot client with a live access token.
// No refresh logic in your code; the token-service handles it.
async handler({ input, hubspot }) {
const contact = await hubspot.crm.objects.contacts.get(String(input.contactId), {
properties: ['email', 'email_health_status'],
});
return ok({ summary: `${contact.properties.email} is ${contact.properties.email_health_status}` });
},
});
export default worker;There is no context.env.HUBSPOT_ACCESS_TOKEN. If you find yourself reaching for one, you are on the wrong path: that token would be stale within minutes and there is no mechanism to refresh it from outside the runtime.
When the token cannot be refreshed
If a merchant uninstalls your app, or a portal admin revokes the OAuth grant from HubSpot’s “Connected apps” UI, the next refresh comes back as BAD_REFRESH_TOKEN and the install flips to uninstalled. The merchant has to re-install the app to mint a new refresh token; there is no admin-side reissue path. (HubSpot does not expose one; the refresh token grant is the merchant’s authorization, and revoking it is final.) A reauth_required install is the transient case: HubSpot was unreachable or answered with a 5xx during the refresh, and the state is a signal to look at HubSpot’s status and your logs rather than at the merchant.
For the developer-side credential (your PAK, used by the CLI rather than the runtime), recovery is a re-paste. Run hs-x connect hubspot again with --pak <pak>, or update $HSX_HUBSPOT_PAK in your shell. There is no automatic rotation of the PAK because the PAK is your personal credential, not an OAuth-managed one.
Set your own runtime secrets with wrangler
Everything that is not a HubSpot OAuth token (the verification provider’s key, your Stripe secret, the HMAC secret your inbound webhook receiver uses, your encryption keys) is a Cloudflare Worker secret today, set with the standard Wrangler CLI and read from context.env in your handler. For Email Guard that is EMAILCHECK_API_KEY: the validate-email action, the email-changed trigger, and the email-health card backend all spend it on the same verification endpoint.
There is no hs-x secrets set EMAILCHECK_API_KEY command in the shipped CLI. The only subcommand of hs-x secrets that exists is hs-x secrets hubspot-oauth set, covered in Step 1. The unified surface is designed but not implemented; see Step 4 for what is on the roadmap.
The wrangler workflow
# Target the Worker script by name (hs-x deploy prints it; so does hs-x status):
wrangler secret put EMAILCHECK_API_KEY --name hsx-<account>-<project>-<worker>
# (prompt) Enter a secret value: <paste, then Enter>
# ✨ Success! Uploaded secret EMAILCHECK_API_KEY
# Or point wrangler at the config hs-x generated for that worker:
wrangler secret put EMAILCHECK_API_KEY --config .hs-x/cloudflare/<worker>.wrangler.tomlPass --name or --config. There is no root wrangler.toml in an HS-X project; the generated config lives at .hs-x/cloudflare/<worker>.wrangler.toml (“Generated by hs-x. Do not edit by hand.”) and has no [env.*] blocks. Do not use wrangler’s --env: HS-X environments are an identity axis, not separate Worker scripts, so a project has exactly one script per worker to put the secret on, and a wrangler environment would target a script HS-X never deploys.
Wrangler sends the value to Cloudflare’s API over TLS. Cloudflare encrypts it at rest, and uploading a secret to an existing Worker publishes a new version of that Worker with the secret bound, so the value is live immediately. It never appears in the generated config, in your git history, or in build output; wrangler secret list --name <worker> shows the names but not the values. (See Cloudflare’s docs on Worker secrets for the underlying mechanism, including the API endpoints and the per-secret size and count limits.)
hs-x deploy re-uploads the script and keeps the secrets already attached to it, so redeploying after wrangler secret put is not required and does not hurt. If you do redeploy, use the same --env you always use for that project: --env selects environment identity, not which secrets get attached. Inside your handler, the value is on context.env. This is the secret-bearing fetch at the heart of validate-email; the workflow-actions guide has the full action around it:
// src/workers/email-guard.ts — inside validate-email's handler
async handler({ input, env }) {
const email = String(input.email ?? '');
// ...format check...
const res = await fetch('https://api.emailcheck.example/v1/verify', {
method: 'POST',
headers: {
authorization: `Bearer ${String(env.EMAILCHECK_API_KEY)}`,
'content-type': 'application/json',
},
body: JSON.stringify({ email }),
});
// ...parse the verdict, write it back, return ok(...)
},context.env is typed as Record<string, unknown> by default. The String(...) at the call site is the explicit acknowledgement that no compile-time manifest exists today linking the secret name to a string type. You can tighten it per capability through the handler-context type parameter (HandlerContext<TInput, TEnv>); defineWorker itself takes none, and the SDK does not generate one from your deployed bindings.
One more surface the same secret reaches: module scope. Email Guard’s suppression-list source reads process.env.EMAILCHECK_API_KEY outside any handler, and that works on the deployed Worker because the generated wrangler config ships the nodejs_compat and nodejs_compat_populate_process_env compatibility flags, which mirror the Worker’s secrets and vars into process.env at startup. Prefer context.env inside handlers (it is the surface the type parameter can tighten), and reserve process.env for declarations that run at import time, like a defineSource auth token.
One script, shared across environments
Because a project has one Worker script per worker, hs-x deploy --env staging and hs-x deploy --env production from the same project land on the same script and see the same EMAILCHECK_API_KEY. The environment value scopes install tokens, the OAuth client secret, flags, and routes; it does not give you a second copy of your own secrets. When staging and production must use different provider keys, keep them as separate HS-X projects (one per target portal, the layout the environments guide recommends) and set the secret on each project’s script.
Rotation
When you wrangler secret put EMAILCHECK_API_KEY --name <worker> with a new value, Cloudflare publishes a new Worker version with the new binding, and new invocations see it from that moment. In-flight invocations that started against the previous version keep the old binding for the rest of their lifetime. Cloudflare does not retroactively swap the binding, and there is no overlap window in which both values serve new requests.
Practical implication: for a sub-second action like validate-email, a rotation is invisible. For a suppression-list sync run already in flight when you rotate, that run completes with the old key and the next five-minute fire uses the new one. If the verification provider invalidates the old key the instant the new one is created, the in-flight run will fail on its next API call: provision the new key first, rotate the Worker secret, then delete the old key from the provider once you are confident no long-running invocations are still reading it.
Wire local-dev values through .dev.vars
Local development reads from .dev.vars in your project root, next to hsx.config.ts. It is a dotenv-format file (KEY=value, one per line), the same convention Wrangler established, and hs-x dev reads it on every local invocation: the values land on context.env and are mirrored into process.env (without clobbering real shell exports), matching what the deployed Worker’s compatibility flags do. Your handler reads env.EMAILCHECK_API_KEY whether it is running on your laptop or in production, and the value is resolved from .dev.vars locally and from the Cloudflare secret binding in production.
# .dev.vars at the project root, gitignored
EMAILCHECK_API_KEY=ec_test_abc123
HMAC_INBOUND=local-dev-only-do-not-deployAdd .dev.vars to .gitignore immediately. The default HS-X project scaffold includes it, but if you migrated a project structure or worked through a cleanup, double-check. The same applies to .env.local, .env, and any editor swap files for those names.
# .gitignore
.dev.vars
.dev.vars.*
.env
.env.localWhat .dev.vars is and is not
.dev.vars is a plaintext file on your laptop. It is not encrypted, not synced, not part of any deploy bundle, and not readable by anyone else’s hs-x dev run. Treat it the way you treat your shell history: yours alone, not transferable. If your laptop is wiped, you re-create .dev.vars from a password manager or by re-fetching keys from the upstream providers. There is intentionally no “pull production secrets to my laptop” command: production values should not land on a developer workstation.
The file is re-read on every invocation, so adding or changing a value takes effect on the next hs-x dev invoke or the next tunneled request, with no dev-server restart. The one exception is a value read at module scope through process.env (like a defineSource auth token): the mirror never overwrites a key that is already set, so an edited value reaches context.env immediately but module-scope reads keep the first value they saw until you restart the session. The HubSpot OAuth side of hs-x dev does not need anything in .dev.vars: install tokens for your dev portal still resolve through the runtime token service against the install record stored in your project’s dev environment. See the local dev guide for the full local-dev loop, including how the tunnel proxies requests from your dev portal back to your laptop.
Common .dev.vars pitfalls
- Whitespace in values. Dotenv parsers handle quoted strings, but unquoted values with trailing spaces silently break. If your key looks right but the API rejects it, wrap it:
FOO="value with spaces". - Missing variable.
env.EMAILCHECK_API_KEYisundefinedif the key is not in.dev.vars; with Email Guard’s handler that surfaces as a 401 from the verification API at the call site. The fix is to add the line; the next invocation reads it. - Loading the wrong file.
hs-x devreads.dev.varsfrom the directory it runs in (or the one you pass with--cwd), which must be the project root containinghsx.config.ts. Running from a subdirectory does not find it; pass--cwd <project-root>.
Preview: the unified hs-x secrets surface
This part of the surface is designed but not yet shipped. The only hs-x secrets subcommand that exists in the CLI today is hs-x secrets hubspot-oauth set, the install-OAuth credential setter covered in Step 1. The general-purpose hs-x secrets <name> --env <env> surface described below is the design target, not the current behavior. Until it ships, use the wrangler workflow in Step 2.
The design target collapses the two-tool workflow into one. Instead of wrangler secret put EMAILCHECK_API_KEY --name <worker> against a script name you looked up, the unified surface is:
# Design target — not yet implemented:
hs-x secrets set EMAILCHECK_API_KEY --env production
hs-x secrets list --env production
hs-x secrets diff staging production
hs-x secrets unset EMAILCHECK_API_KEY --env productionThe motivation is three-fold: (1) a single CLI to learn for everything HS-X-shaped; (2) a compile-time manifest of declared secret names so a misspelled context.env.EMAILCHECK_API_KE (note the missing Y) is a TypeScript error at the call site, not a runtime undefined; (3) a unified audit trail in the control-plane audit log so “who set this and when” answers the same way for HubSpot install tokens and for third-party API keys.
The runtime resolution mechanism does not change: secrets still live in Cloudflare’s encrypted store and bind onto the Worker at deploy time. The change is purely at the author surface: one CLI, one manifest, one place to look. When this lands, this guide will be rewritten with the unified flow as the primary path and the wrangler workflow as the escape hatch.
The install-OAuth setter (hs-x secrets hubspot-oauth set) is the reference implementation for the per-environment scope: a value stored in the control plane against (account, project, environment, app) and installed on the Worker by the next deploy. The general surface would follow the same shape.
Recover from the common failures
A short field guide to the errors you are most likely to see today. The first three are CLI/setup failures; the rest are runtime.
HubSpot PAK is required. Pass --pak, set HSX_HUBSPOT_PAK, or configure a HubSpot CLI account (see hs-x doctor).
The CLI cannot find your HubSpot developer personal access key. This blocks any CLI command that calls HubSpot’s developer-side APIs (hs-x deploy, hs-x api); hs-x connect hubspot reports the same gap as HSX_E_INPUT_MISSING_PAK with the hint to run hs accounts auth first so it can auto-discover. The fix, in order of preference: (1) export HSX_HUBSPOT_PAK=<pak> in your shell profile so it is present for every session; (2) pass --pak <value> to the one command; (3) run hs accounts auth in the HubSpot CLI first, which writes a PAK to its config, and HS-X will discover it on the next run (hs-x doctor shows what it found). The PAK is a developer credential, not an OAuth token: you grab it from your developer-account settings in HubSpot, paste it once, and forget about it.
OAuth client secret not yet on the Worker (503 hubspot_signature_verification_unavailable)
You ran hs-x secrets hubspot-oauth set but have not deployed since, or you deployed a different environment than the one you stored the secret under. The control plane holds the value; only a deploy installs it. Run hs-x deploy --env <the environment you stored it for>. On an unlinked project there is no stored slot at all: pass --hubspot-client-secret (or HSX_HUBSPOT_CLIENT_SECRET) to hs-x deploy.
HubSpot OAuth grant revoked (install moves to uninstalled)
When the runtime tries to refresh a token and HubSpot answers with BAD_REFRESH_TOKEN (or the OAuth invalid_grant error), the install state flips to uninstalled; the handler that ran the failing refresh sees TOKEN_REFRESH_FAILED, and every later call for that install is refused with TOKEN_STATE_NOT_ACTIVE before any HubSpot request is made. There is no automatic recovery: a refresh token, once revoked, cannot be re-minted from outside the merchant’s authorization. The merchant has to re-install your app (which walks the OAuth flow fresh and writes a new install record). There is no hs-x command that can shortcut this; the limitation is on HubSpot’s side.
An install sitting in reauth_required is different: the refresh failed for a transient reason (HubSpot unreachable, a 5xx, a rate limit). The token service will not retry on its own while the install is in that state, so treat it as an alert to investigate, not as a revoked grant.
For the developer-side credential, recovery is a re-paste: hs-x connect hubspot --pak <pak> updates the PAK without touching anything else.
Worker handler sees env.FOO as undefined
The secret is not bound to the Worker script that is running the handler. The three likely causes, in order of frequency:
- You set the secret on a different script.
wrangler secret putwithout--nameor--configneeds awrangler.tomlin the current directory, and an HS-X project has none at the root; with--env <x>it targets a wrangler environment HS-X never deploys. Runwrangler secret list --name hsx-<account>-<project>-<worker>to confirm what the live script holds, then re-set with--name. - You set it on the right script, but a different worker reads it. Each
defineWorkerdeploys as its own script with its own secrets. A secret one worker needs has to be put on that worker’s script. - The handler is running locally and
.dev.varsis missing the key, orhs-x devstarted in the wrong directory.hs-x devreads.dev.varsfrom its working directory (or--cwd), which must be the project root next tohsx.config.ts; it never reads your deployed Worker’s secrets. Add the line to that file, or restart with--cwd <project-root>; the next invocation reads it.
The fix in every case is to verify which surface is being read (wrangler secret list --name <worker> for the deployed script, cat .dev.vars for local) and then set the value in the surface that is actually being consumed.
Worker still seeing the old value after a rotate
wrangler secret put publishes a new Worker version immediately, so an old value on new invocations almost always means you rotated a different script: check wrangler secret list --name <worker> against the name in hs-x status, and remember each worker has its own script. If the right script has the new value but old reads persist, the most likely explanation is an in-flight invocation that started against the previous version and holds the old binding for its lifetime. Wait for it to finish (or kill it through whatever your upstream surface allows). For long-running scheduled syncs, design for restartability and rotate at a quiet hour.
.dev.vars committed by accident
If the commit is on a local branch you have not pushed: git reset HEAD~1 -- .dev.vars && git commit --amend --no-edit removes the file from the commit, then add .dev.vars to .gitignore.
If the commit is on a branch you pushed to a private remote: rotate every value that was in .dev.vars immediately, even though the remote is “private”: every developer clone and every CI runner cache now has the values. If any of those values are the same as ones you set on the deployed Worker, rotate those too (wrangler secret put --name <worker>). Then rewrite git history with git filter-repo --path .dev.vars --invert-paths and force-push; anyone who already cloned still has the leaked values, so the rotation is the real fix and the rewrite is damage limitation.
If the commit is on a public repo: assume full compromise. Rotate everything upstream first, rotate the deployed copies, then deal with history.
