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 are: 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; and local-dev values, the same kinds of values as the second bucket, but resolved against your laptop while you run hs-x dev instead 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, which is internal plumbing for the install OAuth flow and is not the same thing.
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 (very close to 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 flips back to active. When the refresh fails (typically because the merchant or admin revoked the OAuth app on the HubSpot side), the install state flips to reauth_required and every subsequent handler call throws TOKEN_REFRESH_FAILED until someone re-authorizes.
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>The first command links your HS-X project 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 pushes your HubSpot OAuth app's client_id and client_secret into the tenant Cloudflare Worker as bindings. 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. The secret is bound per environment via the optional --env production|staging|dev flag. (Despite the hs-x secrets prefix, this command is purpose-built for the install-OAuth credential and is not a general-purpose secrets surface; that distinction matters when you reach Step 4.)
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 fails with a TOKEN_REFRESH_FAILED error and the install owner state flips to reauth_required. 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.)
For the developer-side credential (your PAK, used by the CLI rather than the runtime), recovery is just re-pasting. Run hs-x connect hubspot again with --pak <new_value>, 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
# In your project directory, against the Worker that runs your handlers:
wrangler secret put EMAILCHECK_API_KEY
# (prompt) Enter a secret value: <paste, then Enter>
# ✨ Success! Uploaded secret EMAILCHECK_API_KEY
wrangler secret put EMAILCHECK_API_KEY --env staging
# same flow, scoped to the staging environment in wrangler.tomlWrangler sends the value to Cloudflare's API over TLS. Cloudflare encrypts it at rest in their secret store and binds it onto the next deployment of the target Worker. The value never appears in wrangler.toml, in your git history, or in build output; wrangler secret list 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.)
On the next hs-x deploy, the secret is bound to your Worker. Inside your handler, it is on context.env; this is the verification call at the heart of validate-email, the same fetch the workflow-actions guide builds:
import { defineWorker, failContinue, ok } from '@hs-x/sdk';
const worker = defineWorker('email-guard');
worker.action('validate-email', {
label: 'Validate email address',
objectType: 'contact',
input: { email: { type: 'string', label: 'Email to validate' } },
output: {
status: { type: 'enumeration', options: ['deliverable', 'risky', 'undeliverable'] },
score: { type: 'number' },
},
async handler({ input, env }) {
const email = String(input.email ?? '');
if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) {
return failContinue('Not a valid email address', { status: 'undeliverable', score: 0 });
}
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 }),
});
const verdict = (await res.json()) as {
status: 'deliverable' | 'risky' | 'undeliverable';
score: number;
};
return ok({ status: verdict.status, score: verdict.score });
},
});
export default worker;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 that locally by passing a type parameter on the worker, but 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.
Per-environment scope
If your wrangler.toml declares environments (typical: [env.staging], [env.production]), each wrangler secret put call is scoped to one environment. Setting EMAILCHECK_API_KEY in staging and then in production produces two independent ciphertexts bound to two independent Worker deployments. There is no built-in "set in all environments", and that is deliberate: a staging key in production is almost always a bug.
Rotation
Cloudflare rotates a Worker secret on the next deployment. When you wrangler secret put EMAILCHECK_API_KEY with a new value and then hs-x deploy, the new deployment binds the new value. In-flight invocations that started against the old deployment keep the old binding for the rest of their lifetime. Cloudflare does not retroactively swap the binding. New invocations against the new deployment immediately see the new value. There is no overlap window in the Cloudflare runtime; the old value is gone from new invocations the moment the new deploy is live.
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 Dev mode 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 devloads.dev.varsfrom the project root (the directory withhsx.config.ts), not from your shell's$PWD. If you run it from a subdirectory, the file is still loaded from the project root.
Preview: the unified hs-x secrets surface
This part of the surface is documented in the SDK and DX spec but is not yet shipped (current ETA TBD). The only hs-x secrets subcommand that exists in the CLI today is hs-x secrets hubspot-oauth set, which is 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 --env production followed by a separate hs-x deploy --env production, 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 context.env.EMAILCHECK_API_KE 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 how the per-environment scope, the binding push to Cloudflare, and the control-plane audit event are wired today.
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 or set HSX_HUBSPOT_PAK.
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 connect hubspot, hs-x api). The fix, in order of preference: (1) export HSX_HUBSPOT_PAK=<your-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 interactive run. 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.
HubSpot OAuth grant revoked (install moves to reauth_required)
When the runtime tries to refresh a token and HubSpot returns BAD_REFRESH_TOKEN (or any refresh-endpoint failure), the install owner state flips to reauth_required and TOKEN_REFRESH_FAILED is thrown on every subsequent handler call for that install. 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.
For the developer-side credential, recovery is a re-paste: hs-x connect hubspot --pak <new> updates the PAK without touching anything else.
Worker handler sees env.FOO as undefined
The secret is not bound to the Worker that is running the handler. The four likely causes, in order of frequency:
- You set the secret with
wrangler secret putbut did not redeploy. Cloudflare binds secrets at deployment time. Runhs-x deploy --env <env>to pick it up. - You set the secret in the wrong environment.
wrangler secret put EMAILCHECK_API_KEYwithout--envwrites to your default environment, which is often notproduction. Runwrangler secret list --env productionto confirm, then re-set with the explicit env flag. - The handler is running locally and
.dev.varsis missing the key.hs-x devreads from.dev.vars, not from your deployed Worker's secrets. Add the line to.dev.vars; the next invocation reads it. - The handler is running locally and
.dev.varsis loaded from the wrong directory. Wrangler loads it from the directory containingwrangler.toml, not your shell's$PWD.
The fix in every case is to verify which surface is being read (wrangler secret list --env <env> for prod, 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
You set the new value with wrangler secret put but hs-x deploy was not run, so the binding on the live Worker still points at the previous ciphertext. The binding only updates on deployment. Run hs-x deploy --env <env> and the next cold start will pick up the new value.
If hs-x deploy was run but the old value is still being observed, the most likely explanation is an in-flight invocation that started against the previous deployment 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 plus hs-x deploy). 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.
