Framework guide

Node & Express

Run Neuctra Authix server-side with a secret key: auth endpoints, per-user data scoped by your own session check, admin reads across every user, and error handling that preserves status codes.

Node 18+Express 4 & 5JavaScript & TypeScript

The secret key removes a safety net

In a browser, a publishable key is pinned to the signed-in user by the server — a client cannot reach anyone else's records. A secret key has no such limit, by design: it can read and write every user in the app.

That means scoping becomes your responsibility. Take the user id from a session you verified, never from the request.

  1. Install

    Bash
    npm install @neuctra/authix express dotenv
    
    # or
    yarn add @neuctra/authix express dotenv
    pnpm add @neuctra/authix express dotenv
  2. Add your keys

    Keep the secret key in the environment and out of source control. Anyone holding it has full authority over your account.

    Bash
    # .env — never commit this file
    AUTHIX_APP_ID=app_xxxxxxxxxxxx
    AUTHIX_SECRET_KEY=sk_live_xxxxxxxx_xxxxxxxxxxxxxxxx
    
    # Only if your server also acts on behalf of a signed-in end user.
    AUTHIX_PUBLISHABLE_KEY=pk_live_xxxxxxxx_xxxxxxxxxxxxxxxx
  3. Create the client

    1// src/authix.js
    2import "dotenv/config";
    3import { NeuctraAuthix } from "@neuctra/authix";
    4
    5/**
    6 * Full account authority: this client can read and write any user in the app.
    7 * Construction throws if the key is malformed or is a publishable key, so a
    8 * misconfiguration fails at boot rather than on the first request.
    9 */
    10export const authix = new NeuctraAuthix({
    11  appId: process.env.AUTHIX_APP_ID,
    12  secretKey: process.env.AUTHIX_SECRET_KEY,
    13  baseUrl: "https://server.authix.neuctra.com/api",
    14});
  4. Expose auth endpoints

    Handling signup on the server lets you enforce your own rules — invite codes, allowed domains, seat limits — before the account exists.

    1// src/routes/auth.js
    2import { Router } from "express";
    3import { authix } from "../authix.js";
    4
    5const router = Router();
    6
    7router.post("/signup", async (req, res, next) => {
    8  try {
    9    const { name, email, password } = req.body;
    10
    11    const result = await authix.signupUser({ name, email, password });
    12
    13    await authix.requestEmailVerificationOTP({
    14      userId: result.user.id,
    15      email,
    16    });
    17
    18    res.status(201).json({ userId: result.user.id });
    19  } catch (error) {
    20    next(error);
    21  }
    22});
    23
    24router.post("/verify", async (req, res, next) => {
    25  try {
    26    const { email, otp } = req.body;
    27    res.json(await authix.verifyEmail({ email, otp }));
    28  } catch (error) {
    29    next(error);
    30  }
    31});
    32
    33router.post("/login", async (req, res, next) => {
    34  try {
    35    const { email, password } = req.body;
    36    res.json(await authix.loginUser({ email, password }));
    37  } catch (error) {
    38    next(error);
    39  }
    40});
    41
    42export default router;
  5. Translate SDK errors into HTTP responses

    1// src/middleware/errors.js
    2
    3/**
    4 * The SDK throws objects carrying { status, code, message }. Passing the status
    5 * through keeps your API honest — a 409 from Neuctra Authix should not become a 500
    6 * from you, because the caller can act on a 409 and cannot act on a 500.
    7 */
    8export function authixErrors(error, req, res, next) {
    9  if (!error?.status) return next(error);
    10
    11  const body = { message: error.message, code: error.code };
    12
    13  if (error.code === "VERSION_CONFLICT") {
    14    body.currentVersion = error.currentVersion;
    15  }
    16
    17  // Never forward an INSUFFICIENT_SCOPE upstream — it means your server used
    18  // the wrong key, which is your bug, not the caller's.
    19  if (error.code === "INSUFFICIENT_SCOPE") {
    20    console.error("Neuctra Authix scope error — wrong key for", req.path);
    21    return res.status(500).json({ message: "Server misconfiguration." });
    22  }
    23
    24  res.status(error.status).json(body);
    25}

Scoping per-user data

The single most important pattern on this page.

1// src/routes/notes.js
2import { Router } from "express";
3import { authix } from "../authix.js";
4import { requireUser } from "../middleware/requireUser.js";
5
6const router = Router();
7
8router.use(requireUser);
9
10/**
11 * The secret key can read ANY user, so the scoping the browser gets for free
12 * is now your job. Always take the user id from the verified session, never
13 * from the request body or the URL.
14 */
15router.get("/", async (req, res, next) => {
16  try {
17    const page = await authix.getUserData({
18      userId: req.user.id, // <- trusted, from the session
19      category: "notes",
20      limit: 20,
21      cursor: req.query.cursor,
22    });
23
24    res.json(page);
25  } catch (error) {
26    next(error);
27  }
28});
29
30router.post("/", async (req, res, next) => {
31  try {
32    const created = await authix.addUserData({
33      userId: req.user.id,
34      dataCategory: "notes",
35      data: { title: req.body.title, body: req.body.body },
36    });
37
38    res.status(201).json(created);
39  } catch (error) {
40    next(error);
41  }
42});
43
44export default router;

Where this goes wrong

Accepting userId from req.body or req.params turns your endpoint into an open door: any caller can read any user by changing one value. It is the most common way a server-side integration leaks data, and it is invisible in testing because your own client always sends the right id.

Admin operations

Listing and searching across users is exactly what a secret key is for.

1// src/routes/admin.js
2import { Router } from "express";
3import { authix } from "../authix.js";
4import { requireAdmin } from "../middleware/requireAdmin.js";
5
6const router = Router();
7
8router.use(requireAdmin);
9
10// Reads across every user in the app. Secret key only.
11router.get("/users", async (req, res, next) => {
12  try {
13    res.json(
14      await authix.getAllUsersFromApp({
15        limit: 50,
16        cursor: req.query.cursor,
17      }),
18    );
19  } catch (error) {
20    next(error);
21  }
22});
23
24router.get("/users/search", async (req, res, next) => {
25  try {
26    res.json(
27      await authix.searchInAllAppUsers({
28        q: req.query.q,
29        keys: req.query.role ? { role: req.query.role } : undefined,
30        limit: 50,
31      }),
32    );
33  } catch (error) {
34    next(error);
35  }
36});
37
38export default router;

Filters on user search are restricted to an allowlist — id, username, name, email, phone, address, role, isVerified, isActive. Anything else is rejected, so credential columns can never be filtered on.

Changing records together

When a partial write would leave your data inconsistent, use a batch.

1// Two records that must change together, or not at all.
2await authix.batch({
3  userId,
4  operations: [
5    { type: "create", dataCategory: "orders", total: 40, status: "placed" },
6    { type: "update", id: stockId, version: stock.version, remaining: 9 },
7    { type: "delete", id: draftId },
8  ],
9});

Every operation commits or none do. A version mismatch or a missing record aborts the whole batch, so an order can never be recorded without the stock deduction that goes with it.

Wiring it together

1// src/server.js
2import express from "express";
3import authRoutes from "./routes/auth.js";
4import noteRoutes from "./routes/notes.js";
5import adminRoutes from "./routes/admin.js";
6import { authixErrors } from "./middleware/errors.js";
7
8const app = express();
9app.use(express.json());
10
11app.use("/api/auth", authRoutes);
12app.use("/api/notes", noteRoutes);
13app.use("/api/admin", adminRoutes);
14
15app.use(authixErrors);
16
17app.listen(3000, () => console.log("listening on :3000"));

Project structure

A layout that keeps the scoping decision in one obvious place.

Bash
src/
├── authix.js                 # one client, secret key
├── server.js                 # express app
├── middleware/
│   ├── errors.js             # maps SDK errors to HTTP responses
│   ├── requireUser.js        # your session check
│   └── requireAdmin.js       # your role check
└── routes/
    ├── auth.js               # signup, verify, login
    ├── notes.js              # per-user data, scoped by YOU
    └── admin.js              # cross-user reads

Common mistakes

  • Trusting a client-supplied user id. Take it from the verified session, always.
  • Returning Neuctra Authix errors verbatim. An INSUFFICIENT_SCOPE means your server used the wrong key — surfacing it tells the caller about your internals and suggests they did something wrong.
  • Collapsing every failure into a 500. A 409 is actionable and a 500 is not.
  • Shipping the secret key to a client. If it ever reaches a browser or a mobile binary, rotate it.
  • Looping single deletes. Use deleteManyUserData — one transaction, up to 100 ids.

You now have

  • Server-side signup, verification and login endpoints.
  • Per-user data reads scoped to a session you verified.
  • Admin endpoints that read across every user.
  • Errors that keep their meaning as they cross your API.

Pairing with a frontend

You do not have to proxy everything. A common split is: the browser uses a publishable key for the signed-in user's own data — where the server scopes it for you — and your Node service handles only what genuinely needs a secret key, such as admin views and billing.

Related