view .md
How to · Ship

Secrets: what HS-X manages for you, and what you wire yourself.

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.

Time
≈ 10 min
Outcome
A working understanding of which secrets HS-X manages (HubSpot OAuth), which you manage via Cloudflare today (your runtime API keys), and what the unified secrets surface will look like when it ships.
Prerequisites
  • An hs-x project already scaffolded and wired to a HubSpot developer account and a Cloudflare account — the Getting Started guide covers hs-x init and hs-x connect.
  • wrangler installed locally, or available via your package manager (the Cloudflare Workers CLI). HS-X does not wrap wrangler for the third-party-secrets case today.
  • A third-party API key on hand to use as the worked example. A Resend key, a Stripe test key, an HMAC signing secret — anything you would not check into git.

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". They are not. They 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 — third-party API keys, signing secrets, encryption keys, the things 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. The unified hs-x secrets <name> --env <env> surface that some early drafts of these docs describe 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 — uses Cloudflare's standard .dev.vars file, the same way any Wrangler-built Worker does. hs-x dev shells through to Wrangler under the hood, so anything Wrangler reads, your local handler reads.

What owns what today

CategoryWho sets itWho reads it at runtimeHow it rotates
HubSpot OAuth tokens (per install)HS-X, via the install OAuth flowcontext.hubspot in your handlerHS-X runtime, on demand (token-service lanes)
Cloudflare Worker secretsYou, via wrangler secret putcontext.env.FOO in your handlerYou, by setting again and redeploying
Local-dev valuesYou, in .dev.varscontext.env.FOO during hs-x devYou, by editing the file

That table is the whole mental model. The rest of this guide walks each row in turn, ending with an honest design-preview block for the unified surface and a field guide to the failures you will actually see.

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.

The token state machine lives in packages/runtime/src/token-service.ts. 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.

import { defineWorker } from '@hs-x/sdk';
 
export default defineWorker(({ worker }) => {
  worker.action('createDeal', async ({ input, hubspot }) => {
    // hubspot is the runtime-resolved HubSpot client with a live access token.
    // No refresh logic in your code; the token-service handles it.
    const deal = await hubspot.crm.deals.basicApi.create({
      properties: { dealname: input.name, amount: String(input.amount) },
    });
    return { dealId: deal.id };
  });
});

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

This is where the honest part of the story starts. Everything that is not a HubSpot OAuth token — your Resend API 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.

There is no hs-x secrets set RESEND_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 RESEND_API_KEY
# (prompt) Enter a secret value: <paste, then Enter>
# ✨  Success! Uploaded secret RESEND_API_KEY
 
wrangler secret put RESEND_API_KEY --env staging
# same flow, scoped to the staging environment in wrangler.toml

Wrangler 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:

import { defineWorker } from '@hs-x/sdk';
 
export default defineWorker(({ worker }) => {
  worker.action('sendWelcomeEmail', async ({ input, env }) => {
    const res = await fetch('https://api.resend.com/emails', {
      method: 'POST',
      headers: {
        authorization: `Bearer ${env.RESEND_API_KEY as string}`,
        'content-type': 'application/json',
      },
      body: JSON.stringify({
        from: 'noreply@example.com',
        to: input.email,
        subject: 'Welcome',
        html: '<p>Hello</p>',
      }),
    });
    return { id: ((await res.json()) as { id: string }).id };
  });
});

context.env is typed as Record<string, unknown> by default (see HandlerContext.env in packages/sdk/src/index.ts). The cast 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.

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 RESEND_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" — by design, since 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 RESEND_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 30-second action, a rotation is invisible. For an hourly scheduled handler that is already mid-run when you rotate, that run completes with the old key and the next run uses the new one. If your upstream 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 upstream 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. It is a dotenv-format file (KEY=value, one per line) and Wrangler loads it into context.env whenever you run a Worker locally. hs-x dev runs your Worker locally through Wrangler, so the same mechanism applies — your handler reads env.RESEND_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
RESEND_API_KEY=re_test_abc123
HMAC_INBOUND=local-dev-only-do-not-deploy

Add .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.local

What .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 values in .dev.vars are read by Wrangler at dev-server startup. If you add a variable while the dev server is running, restart it (Ctrl+C, hs-x dev again) so Wrangler picks up the new file. 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 WebSocket 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.FOO is undefined if the key is not in .dev.vars. You will see Cannot read properties of undefined or a 401 from the upstream API at the call site. The fix is to add the line and restart the dev server.
  • Loading the wrong file. Wrangler loads .dev.vars from the project root that contains your wrangler.toml, not from your shell's $PWD. If you run hs-x dev from a subdirectory, the file is still loaded from the project root.

Preview: the unified hs-x secrets surface

Design preview

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 RESEND_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 RESEND_API_KEY --env production
hs-x secrets list --env production
hs-x secrets diff staging production
hs-x secrets unset RESEND_API_KEY --env production

The 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.RESEND_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.

For the curious, the spec lives at docs/hs-x-sdk-and-dx-spec.md in the repo, and 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 will actually 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:

  1. You set the secret with wrangler secret put but did not redeploy. Cloudflare binds secrets at deployment time. Run hs-x deploy --env <env> to pick it up.
  2. You set the secret in the wrong environment. wrangler secret put RESEND_API_KEY without --env writes to your default environment, which is often not production. Run wrangler secret list --env production to confirm, then re-set with the explicit env flag.
  3. The handler is running locally and .dev.vars is missing the key. hs-x dev reads from .dev.vars, not from your deployed Worker's secrets. Add the line to .dev.vars and restart the dev server.
  4. The handler is running locally and .dev.vars is loaded from the wrong directory. Wrangler loads it from the directory containing wrangler.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.

Where next

  • Getting started with HS-X — the end-to-end project scaffold and hs-x connect flow, if you have not wired your HubSpot and Cloudflare accounts yet.
  • Dev mode — the full hs-x dev loop, including the tunnel, hot reload, and how .dev.vars fits in alongside your live dev portal.
  • Monitoring — how to surface a missing-secret error in production before a user does, and how to read the control-plane audit log (hs-x audit list) for account-level events.