Recipe: Sign Widget Tokens server-to-server
Your server signs the Widget Token locally using your Shop 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 Shop API Key (
sk_…) 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:unixTimestampusing the Shop API Key as HMAC secret. - The result is the colon-joined concatenation, encoded as URL-safe Base64 (no padding).
Caching strategy
Your server should:
- Cache the token after the first signing.
- Re-sign with a 5 min margin before 24 h.
- Lock the critical section to avoid concurrent signing (not strictly required; signing is idempotent, but it saves work).
- (Optional) Distribute the cache across instances (Redis, KV) if you run on serverless or multi-pod.
Reference implementations
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 Shop 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.