---
title: "Quickstart"
description: "Four steps from a signed redirect URL to a verified callback. The redirect flow needs nothing but the server SDK."
source: "/docs/quickstart"
updated: "2026-09-24"
---

# Verify your first user.

Four steps from a signed redirect URL to a verified callback. The redirect flow needs nothing but the server SDK.

## 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.

```js
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.

> **WARN - Upgrading from 0.1.x**
>
> `createAuthUrl` used to be synchronous and took no `secret`. Add `await` and pass `secret` - a forgotten `await`
> yields a Promise where a `{ url, nonce }` object is expected, and the redirect silently breaks.

## 2. Redirect the user (frontend)

Fetch the URL from your backend and redirect the user's browser to the Helix portal.

> **TIP - UX Note**
>
> Consider showing a brief in-product message before the redirect, e.g. "You'll be briefly redirected to Helix to verify
> your identity, then brought back here." This sets expectations and reduces drop-off when users land on the Helix
> portal.

```js
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.

```js
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;
  }
});
```

> **INFO - TypeScript**
>
> The snippet above is JavaScript. In TypeScript, `Object.fromEntries()` widens to `{ [k: string]: string }`, which will not satisfy the declared parameter type - import the `CallbackQuery` type from the package and assert the normalized object with `as CallbackQuery`.

## 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.

```js
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:

```js
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,
});
```

> **INFO - Enroll vs. verify**
>
> Whether this session enrolls or verifies is decided entirely by whether you pass `enrollmentId` - never infer one from
> a missing ID on your own side. A dropped ID silently creates a duplicate enrollment and bills for it.


## Sitemap

See the full [sitemap](https://helix.id/sitemap.md) for all pages.
