Skip to main content

Recipe: Sign Widget Tokens server-to-server

Your server signs the Widget Token locally using your API Key as the HMAC secret. Without calling Neuroon. The widget receives the token via data-token and Neuroon's backend validates the signature on every request.

Estimated time: 15-25 min the first time.

Why this pattern?​

  • The API Key (Bearer token) never reaches the browser. It only lives on your backend.
  • The Widget Token is opaque for your JavaScript client.
  • If a key leaks, rotate the key in the dashboard and active tokens expire on their own via TTL (24 h).
  • No extra dependency: you don't call Neuroon for each token; signing is a local microsecond operation.

Token format​

Base64URL( shopId : unixTimestamp : HMAC-SHA256(hex)( "shopId:unixTimestamp", secret = shopApiKey ) )
  • shopId: shop_xxx… (your Shop ID).
  • unixTimestamp: UTC seconds at signing time.
  • The signature is computed over shopId:unixTimestamp using the API Key as HMAC secret.
  • The result is the colon-joined concatenation, encoded as URL-safe Base64 (no padding).

Caching strategy​

Your server should:

  1. Cache the token after the first signing.
  2. Re-sign with a 5 min margin before 24 h.
  3. Lock the critical section to avoid concurrent signing (not strictly required; signing is idempotent, but it saves work).
  4. (Optional) Distribute the cache across instances (Redis, KV) if you run on serverless or multi-pod.

Reference implementations​

neuroon-token.js
import {createHmac} from 'node:crypto';

const SHOP_ID = process.env.NEUROON_SHOP_ID;
const API_KEY = process.env.NEUROON_API_KEY;

let cache = null; // { token, expiresAt: epoch ms }

function signToken() {
const ts = Math.floor(Date.now() / 1000);
const payload = `${SHOP_ID}:${ts}`;
const sig = createHmac('sha256', API_KEY).update(payload).digest('hex');
const raw = `${payload}:${sig}`;
const token = Buffer.from(raw, 'utf8').toString('base64url');
return {token, expiresAt: (ts + 24 * 3600) * 1000};
}

export function getWidgetToken() {
const now = Date.now();
if (cache && now < cache.expiresAt - 5 * 60_000) return cache.token;
cache = signToken();
return cache.token;
}

// Express endpoint that the frontend consumes
import express from 'express';
const router = express.Router();
router.get('/internal/neuroon-token', (_req, res) => {
res.json({token: getWidgetToken()});
});
export default router;

Distributed variant with Redis​

In serverless environments (Vercel, Lambda) local memory is lost between cold starts. Use Redis as a shared cache:

import Redis from 'ioredis';
import {createHmac} from 'node:crypto';

const redis = new Redis(process.env.REDIS_URL);

async function getWidgetTokenShared() {
const cached = await redis.get('neuroon:widget-token');
if (cached) {
const {token, expiresAt} = JSON.parse(cached);
if (Date.now() < expiresAt - 5 * 60_000) return token;
}
// sign locally
const ts = Math.floor(Date.now() / 1000);
const payload = `${process.env.NEUROON_SHOP_ID}:${ts}`;
const sig = createHmac('sha256', process.env.NEUROON_API_KEY).update(payload).digest('hex');
const token = Buffer.from(`${payload}:${sig}`, 'utf8').toString('base64url');
const expiresAt = (ts + 24 * 3600) * 1000;
await redis.set('neuroon:widget-token', JSON.stringify({token, expiresAt}), 'PX', 23 * 3600 * 1000);
return token;
}

Forced rotation​

If you need to invalidate all active tokens cold (after an incident, credential rotation), rotate the API Key in the Dashboard. Widget Tokens signed with the old key stop validating immediately; your server starts signing with the new one on the next render.

Frontend injection​

Once you have the token cached, your frontend receives it via:

  • SSR / RSC: the HTML prints data-token="..." at render time.
  • SPA: your private API (GET /internal/neuroon-token) returns it and an effect injects it into the CDN <script>.

Full pattern at plugins/custom/standalone and plugins/custom/nextjs.