1. Generate the redirect URL (backend)
Call createAuthUrl() on your backend to generate a unique, signed URL and a cryptographic nonce. Store the nonce in the user's session before redirecting.
createAuthUrl HMAC-SHA256-signs the URL params with your shared secret and appends the signature as a sig query param, so it needs the secret option. It is async — it uses the Web Crypto API, so it returns a Promise and must be awaited.
import { createAuthUrl } from "@helix-id/node";
app.get("/auth/start", async (req, res) => {
const state = Buffer.from(JSON.stringify({ action: "approve_tx", txId: "abc123", returnTo: "/dashboard" })).toString(
"base64",
);
const { url, nonce } = await createAuthUrl({
spaceId: process.env.HELIX_SPACE_ID,
state,
// Required. The browser origin your callback is served from —
// Helix targets its postMessage at exactly this origin.
origin: process.env.APP_ORIGIN,
// Required. Signs the URL params; the signature rides along as `sig`.
secret: process.env.HELIX_SECRET,
});
req.session.helixNonce = nonce;
// Return only `url` — it already carries `sig`. Never send the secret to the browser.
res.json({ url });
});The result also carries the raw signature as sig if you need it separately; the url already includes it. Do not append, reorder or rewrite query params after signing — any edit invalidates the signature.
2. Redirect the user (frontend)
Fetch the URL from your backend and redirect the user's browser to the Helix portal.
const { url } = await fetch("/auth/start").then((r) => r.json());
window.location.href = url;3. Verify the callback (backend)
After the user completes the voice challenge, Helix redirects to your callback endpoint. Call verifyCallback() to validate the HMAC signature, nonce, and timestamp.
import {
verifyCallback,
InvalidSignatureError,
NonceMismatchError,
ExpiredTimestampError,
MalformedCallbackError,
} from "@helix-id/node";
app.get("/auth/callback", async (req, res) => {
// verifyCallback expects every field to be a plain string.
// Express may parse duplicate query params as arrays —
// normalize req.query before passing it in.
const query = Object.fromEntries(Object.entries(req.query).map(([k, v]) => [k, Array.isArray(v) ? v[0] : String(v)]));
try {
const result = await verifyCallback(query, {
nonce: req.session.helixNonce,
secret: process.env.HELIX_SECRET,
});
req.session.helixNonce = null;
const state = JSON.parse(Buffer.from(result.state, "base64").toString());
if (result.status === "verified") {
res.redirect(state.returnTo);
} else {
res.redirect(`${state.returnTo}?error=${result.statusCode}`);
}
} catch (error) {
// every Helix error carries a stable .code, e.g. INVALID_SIGNATURE
if (error instanceof InvalidSignatureError) return res.status(401).json({ error: error.code });
if (error instanceof NonceMismatchError) return res.status(401).json({ error: error.code });
if (error instanceof ExpiredTimestampError) return res.status(401).json({ error: error.code });
if (error instanceof MalformedCallbackError) return res.status(400).json({ error: error.code });
throw error;
}
});4. Store & reuse the Enrollment ID (backend)
If your Space has Voice MFA or Banned List enabled, verifyCallback returns an enrollmentId. Store it against the user — it is how Helix recognizes them on their next voice-auth session instead of enrolling a duplicate.
const result = await verifyCallback(query, { nonce: req.session.helixNonce, secret: process.env.HELIX_SECRET });
if (result.enrollmentId) {
await db.users.update(userId, { helixEnrollmentId: result.enrollmentId });
}Pass it back into createAuthUrl the next time that same user starts a voice-auth session:
const { url, nonce } = await createAuthUrl({
spaceId: process.env.HELIX_SPACE_ID,
state,
origin: process.env.APP_ORIGIN,
secret: process.env.HELIX_SECRET,
// Omit on a user's first session — Helix enrolls them and returns a fresh Enrollment ID.
enrollmentId: user.helixEnrollmentId,
});