[object Object]

The private app token sitting in your .env file is a long-lived secret with full scope to a production CRM. Most teams treat it like a wifi password. Then somebody pushes it to a public repo, or a developer leaves, or a contractor laptop walks out the door, and now you are rotating in a panic at 11pm with a workflow that uses it firing every 30 seconds.

Rotate quarterly. Rotate when anyone with access leaves. Rotate now if you have never rotated before. Here is the runbook that does not break production.

Why private apps replaced API keys, and what that changed

API keys were single-token, single-scope, single-revoke. Private apps are scoped to specific permissions per app and can coexist. That is what makes safe rotation possible. You can hold two valid tokens at once.

If you are still using legacy API keys in any integration, that is the first migration. There is no such thing as a “rotation” for an API key. There is only “burn it and pray nothing was using it.”

The dual-token overlap pattern

Never rotate in place. Always have two valid tokens during the cutover.

T-0   Create new private app with identical scopes
T-1   Add new token as HUBSPOT_TOKEN_NEXT in secret store
T-2   Deploy code that reads HUBSPOT_TOKEN_NEXT if present, else HUBSPOT_TOKEN
T-3   Verify new token in production via canary call
T-4   Swap: HUBSPOT_TOKEN becomes new value, HUBSPOT_TOKEN_NEXT removed
T-5   Old private app: revoke

The code change at T-2 is the only one that ships. Everything after is a config change.

function getToken() {
  return process.env.HUBSPOT_TOKEN_NEXT || process.env.HUBSPOT_TOKEN;
}

async function hsRequest(path, options = {}) {
  const token = getToken();
  return fetch(`https://api.hubapi.com${path}`, {
    ...options,
    headers: {
      Authorization: `Bearer ${token}`,
      "Content-Type": "application/json",
      ...options.headers,
    },
  });
}

This pattern survives partial rollouts. If you deploy to half the fleet first, the half with NEXT set uses the new token, the half without uses the old. Both work until T-5.

Scope is the audit, not the rotation

When you create the new app, do not blindly mirror the old scopes. Rotation is the only time anyone looks at scopes. Trim them.

Common scope rot in HubSpot private apps:

  • crm.objects.contacts.write when the app only reads
  • automation when the app does not trigger workflows
  • content when the app does not touch CMS
  • forms when the app only reads form submissions, not creates forms

The minimum scope is the principle. The annual rotation is when you act on it. See our HubSpot OAuth scopes minimum post for the full audit playbook; the same principles apply to private apps.

Canary the new token before the swap

A canary call is a single low-stakes read against the new token, in production, before you flip the env var.

async function canary() {
  const res = await fetch(
    "https://api.hubapi.com/account-info/v3/details",
    {
      headers: {
        Authorization: `Bearer ${process.env.HUBSPOT_TOKEN_NEXT}`,
      },
    },
  );
  if (!res.ok) {
    throw new Error(`canary failed: ${res.status} ${await res.text()}`);
  }
  const account = await res.json();
  if (account.portalId !== EXPECTED_PORTAL_ID) {
    throw new Error("canary returned wrong portal");
  }
  return true;
}

If the canary fails, the swap does not happen. The old token is still serving traffic. Nobody notices.

What custom code actions need

Workflow custom code actions in HubSpot have their own secret storage. They do not read your deployment env. If you reference process.env.HUBSPOT_TOKEN in a custom code action, that secret lives in HubSpot’s workflow secrets store, not in your CI.

Rotation has to update both stores. Build a small admin script.

async function rotateWorkflowSecrets(newToken) {
  const workflowsUsingToken = [42, 89, 117]; // your audited list
  for (const id of workflowsUsingToken) {
    await fetch(
      `https://api.hubapi.com/automation/v4/actions/secrets/${id}`,
      {
        method: "PATCH",
        headers: { Authorization: `Bearer ${adminToken}` },
        body: JSON.stringify({
          name: "HUBSPOT_TOKEN",
          value: newToken,
        }),
      },
    );
  }
}

The audited list of workflow IDs is something you maintain as you build them. If you do not have it, the rotation discovery phase finds you a list. Add a tag or naming convention to make it greppable.

Webhooks survive rotation if you do it right

Webhook subscriptions belong to the private app. If you create a new app and migrate to it, you must recreate the subscriptions. There is no “transfer” between apps.

Plan the order:

  1. Create new app, create webhook subscriptions on it pointing to the same endpoint
  2. Your endpoint deduplicates by event ID for the overlap window
  3. Verify new app’s webhooks are firing
  4. Disable webhooks on old app
  5. Revoke old app

Deduplication at the endpoint is the bit that gets skipped. Skip it and you get duplicate event processing during overlap.

Rotation hygiene checklist

  • Tokens live in a secret manager, not in .env files in repos
  • A README in each integration repo lists which HubSpot private app it uses
  • A calendar invite quarterly forces the rotation
  • A break-glass procedure exists for emergency rotation under 1 hour
  • Logs never include tokens, not even truncated

Truncated tokens in logs is a real anti-pattern. The first 6 characters of a HubSpot token are not enough to leak the token, but they are enough to confirm a leaked token came from you in a forensic timeline. Do not log tokens at all.

Bottom line

  • Always have two valid tokens during a cutover; never rotate in place.
  • Rotation is the moment to audit and trim scope, not just refresh the string.
  • Canary the new token with a low-stakes read before flipping the env var.
  • Workflow custom code actions store their own secrets; rotate both stores.
  • Webhook subscriptions belong to the app; plan deduplication for the overlap window.
[object Object]
Share