A security review on a new HubSpot integration stalls for two weeks because the app requested crm.objects.contacts.write, crm.schemas.contacts.write, automation, files, forms, and tickets when it only needed to read three contact properties. Scope sprawl is the most common app rejection reason. HubSpot’s scope model rewards minimum privilege, not least effort.
Read why each scope exists
Every scope in HubSpot maps to an API surface. Before requesting one, write the API calls your integration will make and pull the scopes from the docs page for each. If a scope is not in the call list, do not request it. Review at PR time and again before publishing.
GET /crm/v3/objects/contacts -> crm.objects.contacts.read
PATCH /crm/v3/objects/contacts/{id} -> crm.objects.contacts.write
GET /crm/v3/properties/contacts -> crm.schemas.contacts.read
POST /events/v3/send -> none required (private app token)
Read vs write is the cheapest split
The split that matters most: read-only versus write. A reporting integration almost never needs write. A sync integration needs both. A health check needs read on a single object. Splitting an app into read-only and write variants doubles the configuration but halves the blast radius if a token leaks.
Scope drift over time
HubSpot adds scopes when they ship features. An app installed in 2024 with automation may need automation.workflows.read after a 2026 split. Old tokens keep working, but new installs against the old scope list silently miss capabilities. Audit scope coverage quarterly:
// Compare requested scopes vs scopes the API actually needs
const requested = new Set(app.scopes);
const required = new Set(await deriveScopesFromCalls(app.id));
const missing = [...required].filter(s => !requested.has(s));
const extra = [...requested].filter(s => !required.has(s));
console.log({ missing, extra });
Missing scopes mean broken installs after release; extra scopes mean security review pain.
OAuth refresh and rotation
Access tokens expire in 30 minutes. Refresh tokens in HubSpot do not expire by default but should be rotated when:
- A user’s role changes
- A security incident touches the integration’s storage
- An employee with installation access leaves
- Annually as a baseline
Store refresh tokens encrypted at rest with a customer-scoped key. Never log them, even on debug paths.
storage:
table: oauth_tokens
columns:
portal_id: int (primary)
encrypted_refresh: bytes
access_token: bytes (cache, ttl 25 minutes)
scopes: text[]
rotated_at: timestamp
Required vs optional scopes during install
HubSpot’s install flow lets you mark scopes as required or optional. Mark scopes the integration cannot function without as required, and split nice-to-haves into optional. Users who decline optional scopes still get a working install and can grant later.
Required:
- crm.objects.contacts.read
Optional:
- crm.objects.contacts.write (enables 2-way sync)
- timeline (enables event posting)
This pattern helps install conversion measurably for higher-friction integrations.
Scope-related errors that are not scope errors
A 403 in HubSpot can mean missing scope, but also missing object permission for the install user, archived object, or a paused account. Distinguish in your error handling:
function classifyForbidden(error) {
const msg = error.body?.message || "";
if (msg.includes("scope")) return "missing_scope";
if (msg.includes("portal")) return "portal_paused";
if (msg.includes("permission")) return "user_permission";
return "unknown_403";
}
Logging “missing scope” when the real cause is a paused portal sends users on a wild scope-update goose chase.
What to do this week
List every API call your integration makes, derive the minimum scope set, compare against requested scopes in the app config, and prune anything not on the derived list before your next security review.