v3 Signature Mismatch - Public UIE (Local Dev + Proxy)

Hey Devs! Stuck on a persistent v3 signature mismatch for our public UI Extension that we’re looking for some guidance on:

`hubspot.fetch()` calls to our local Node.js backend (proxied via `local.json` + Cloudflare tunnel, with `CLIENT_SECRET` injected into `hs project dev`) consistently result in a 403 “Invalid HubSpot signature” from our middleware.

Key Findings:

  1. `CLIENT_SECRET` is confirmed identical everywhere.
  2. `@hubspot/api-client`'s `Signature.isValid()` returns `false`, even when passed the full URL (from `req.originalUrl` with documented URI decoding), method, empty raw body (for GET), numeric timestamp, and our client secret.
  3. Our manual Node.js `crypto` calculation for the _same base string_ matches standard online HMAC tools, but this “standard” signature does **not** match the signature header sent by the `hs project dev` proxy.

If I understand correctly, this suggests the base string signed by the `hs project dev` proxy differs from our reconstruction (and likely from what `Signature.isValid()` expects in this proxied scenario).

Question: Are there any known specific nuances to how `hs project dev` proxy constructs its string-to-sign for v3 (e.g., query param order, specific URL encoding details beyond the documented list) that we might be missing?
Happy to provide more detailed logs/code if anyone has encountered this, and might be willing to provide some direction. Thanks in advance for any insight here!

Hi @Milest,

Thanks for reaching out to the Community!

I would like to invite some members of our community who may offer valuable insights.— hey @EMalueg, @KKauper, @09156, @zach_threadint - Could you share your advice with @Milest?

Thanks for taking a look!

Diana

Unfortunately, I can’t help here. I do not use this new UI extensions framework, and I don’t use `Signature.isValid()` from the Hubspot API client. Instead I use `crypto.timingSafeEqual()` after building the signature. Happy to share my code, however, if it would help.

Thank you, @DianaGomez - I look forward to connecting with more of the community!
@KKauper - I appreciate your quick response and insight. I’m definitely open to trying alternative approaches. Anything that you’re able to share would be highly appreciated. Thanks so much again for your help!

No problem. Here is our code:

 // Get incoming request signature
 const requestSignature = params?.headers?.['x-hubspot-signature-v3']
 const requestTimestamp = params?.headers?.['x-hubspot-request-timestamp']

 if (!requestSignature) {
 throw new GeneralError('Did not receive Hubspot signature v3')
 }

 if (!requestTimestamp) {
 throw new GeneralError('Did not receive Hubspot signature v3 timestamp')
 }

 // Validate timestamp
 const MAX_ALLOWED_TIMESTAMP = 300000 // 5 minutes in milliseconds
 const currentTime = Date.now()
 if (currentTime - requestTimestamp > MAX_ALLOWED_TIMESTAMP) {
 throw new GeneralError('Hubspot signature v3 timestamp is invalid')
 }

 // Calculate signature
 const clientSecret = <your secret>
 const body = !['GET', 'DELETE'].includes(params.method) ? JSON.stringify(params.originalBody) : ''
 const source = params.method + params.uri + body + requestTimestamp
 const signature = crypto.createHmac('sha256', clientSecret).update(source).digest('base64')

 // Validate signature
 if (!crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(requestSignature))) 
 {
 throw new NotAuthenticated('Hubspot signature v3 mismatch')
 }

 // Successfully validated

I hope it helps.

Thanks so much for sharing your code, @KKauper! It’s very helpful to see a working manual implementation.

We’ve also been down the manual calculation path (Node.js/Express) and found, like you, that **bleep** is in the details of that base string, especially the exact uri component.
Our manual Node.js crypto calculations actually match standard online HMAC tools for a given base string and our CLIENT_SECRET. However, this “standard” signature still doesn’t match the X-HubSpot-Signature-V3 header sent by the hs project dev proxy when using hubspot.fetch() from our UI extension. This suggests the proxy’s string-to-sign has a nuance we haven’t yet replicated.

We’re also finding that @hubspot/api-client’s Signature.isValid() is similarly failing for us in this proxied dev scenario. Your example reinforces that a precise manual approach can work, so we’re continuing to investigate the exact base string formation by the local dev proxy. Appreciate you posting your solution!

@DianaGomez - Would love to keep this thread / conversation open to everyone while we continue working through a solution, as we will report back with an update when we do in case it helps anyone else out now or in the future.

Hey @Milest, we ran into the exact same issue and spent a while debugging it. Here’s what we found — hopefully this saves someone else the trouble.

Root cause

The hs project dev proxy signs requests with the local URL (after local.json mapping), not the original public URL.

For example, if your local.json maps https://your-backend.com to http://localhost:3000, the CLI:

  1. Receives a `hubspot.fetch()` call targeting `https://your-backend.com/api/endpoint?portalId=123\`
  2. Maps the URL to `http://localhost:3000/api/endpoint?portalId=123\`
  3. Signs with the mapped URL (http://localhost:3000/)
  4. Sends the request to http://localhost:3000/

We confirmed this by reading the CLI source code at @hubspot/app-functions-dev-server/dist/signing.js and AppProxyService.js.

The fix

Reconstruct the URL from what Express actually receives — not from a hardcoded public URL:

// JSconst uri = ${req.protocol}://${req.get(‘host’)}${req.originalUrl};

This works in both local dev and production because in both cases it’s the URL the request was actually sent to.

Other gotchas we found

1. `CLIENT_SECRET` is required — without it, requests fail silently (500 from the proxy, no backend logs at all). The CLI gives no warning. Launch with:

// shellCLIENT_SECRET="your-app-secret" hs project dev

2. GET body mismatch — the CLI sends “” (2 bytes, content-length: 2) as the HTTP body for GET requests, but signs with an empty string ‘’. Use ‘’ for GET/DELETE in your signature calculation.

Working middleware (Node.js/Express)

// JSconst crypto = require('crypto');function verifyHubspotSignature(req, res, next) { const signature = req.header('X-HubSpot-Signature-v3'); const timestamp = req.header('X-HubSpot-Request-Timestamp'); if (!signature || !timestamp) { return res.status(401).json({ error: 'Missing signature headers' }); } if (Date.now() - Number(timestamp) > 300000) { return res.status(401).json({ error: 'Timestamp too old' }); } const body = ['GET', 'DELETE'].includes(req.method) ? '' : JSON.stringify(req.body ?? ''); const uri = ${req.protocol}://${req.get(‘host’)}${req.originalUrl}; const source = req.method + uri + body + timestamp; const expected = crypto .createHmac('sha256', process.env.CLIENT_SECRET) .update(source) .digest('base64'); const sigBuffer = Buffer.from(signature); const expectedBuffer = Buffer.from(expected); if (sigBuffer.length !== expectedBuffer.length || !crypto.timingSafeEqual(sigBuffer, expectedBuffer)) { return res.status(401).json({ error: 'Invalid signature' }); } next();}

Note: this follows the same approach as the official Node.js example in the HubSpot docs. Signature.isValid() from @hubspot/api-client does the exact same HMAC calculation internally (hubspot-api-nodejs/src/utils/signature.ts at master · HubSpot/hubspot-api-nodejs · GitHub) but doesn’t solve the URL reconstruction issue — you still need to pass it the correct URL yourself.

Hope this helps!