Skip to content

Node.js

@forculus/sdk verifies the identity token the edge sends your app and gives you the caller. It runs anywhere with WebCrypto and fetch: Workers, Node 20 or newer, Deno and Bun.

Terminal window
npm install @forculus/sdk

It ships ES modules and CommonJS, with TypeScript types. It has no runtime dependencies, because the JWT library it uses is bundled in.

requireUser(request, env) takes a Request and the variables we set. On Workers those are the bindings your fetch handler receives.

import { requireUser, Unauthorized } from "@forculus/sdk";
export default {
async fetch(request, env) {
try {
const user = await requireUser(request, env);
return new Response(`hello ${user.email ?? user.sub}`);
} catch (err) {
if (err instanceof Unauthorized) return err.toResponse();
throw err;
}
},
};

It returns a User:

interface User {
kind: "user" | "service";
sub: string; // "usr_…", "service:<token id>" for a bypass token, or "agent:<agent id>" for an agent
email: string | null; // null for a bypass token or an agent
role: "viewer" | "editor" | "owner";
workspaceId: string;
agentName: string | null; // the agent's name when an agent called, null for everyone else
}

isAgent(user) says whether one of the workspace’s agents made the request. It and agentName are new in version 0.2.0: with an earlier version, npm install @forculus/sdk@latest.

import { isAgent } from "@forculus/sdk";
const by = isAgent(user) ? `${user.agentName} (agent)` : (user.email ?? user.sub);

See the SDK overview for what each field means and what the SDK checks.

import { Forbidden, hasRole, requireRole } from "@forculus/sdk";
requireRole(user, "editor"); // throws Forbidden unless the caller is an editor or an owner
if (hasRole(user, "owner")) {
// show the settings link
}

Forbidden means the caller is known but may not do this. Answer it with err.toResponse(), which is a 403.

Each framework has its own entry point, so a Next.js app doesn’t load the Express code.

Import For
@forculus/sdk Workers, and anything that hands you a Request
@forculus/sdk/next Next.js server components and route handlers
@forculus/sdk/express Express, Connect, and Fastify’s Express plugin
@forculus/sdk/hono Hono, on Workers, Node, Bun or Deno
@forculus/sdk/node node:http, Koa, anything with Node-style headers

The /next and /express entries read the variables we set from process.env, unless you pass env yourself. /node doesn’t read them itself, so pass it envFromProcess(), as in the example below.

import { getUser, requireUser } from "@forculus/sdk/next";
export default async function Page() {
const user = await getUser();
return <p>hello {user?.email ?? "stranger"}</p>;
}

Both read the request’s headers through next/headers. In a page or other server component, use getUser(), which returns null when there is no valid caller. requireUser() throws Unauthorized, which a server component turns into a 500. Use it in a route handler, and answer with the error’s response:

app/api/me/route.ts
import { Unauthorized } from "@forculus/sdk";
import { requireUser } from "@forculus/sdk/next";
export async function GET() {
try {
const user = await requireUser();
return Response.json({ email: user.email });
} catch (err) {
if (err instanceof Unauthorized) return err.toResponse();
throw err;
}
}

Anywhere next/headers can’t be used, pass the request’s headers in: requireUser({ headers: request.headers }).

import { requireUser, withUser } from "@forculus/sdk/express";
app.use(requireUser());
app.get("/", (req, res) => res.send(`hello ${req.user.email}`));
app.post("/orders", requireUser({ role: "editor" }), placeOrder);

requireUser() answers 401 unauthorized without a valid caller, and 403 forbidden: this needs editor when role is set and the caller has less. Neither reaches your handler. Otherwise it sets req.user.

withUser() lets everyone through and sets req.user when there is a caller, for a page that renders either way.

import { getUser, forculus } from "@forculus/sdk/hono";
app.use("*", forculus());
app.get("/", (c) => c.text(`hello ${getUser(c).email}`));
app.post("/orders", forculus({ role: "editor" }), placeOrder);

forculus() answers 401 or 403 itself and puts the caller on the context. It reads the variables we set from Hono’s bindings on Workers, and from process.env on Node, Bun and Deno, so the same line works in a container.

getUser(c) throws if forculus() didn’t run on that route.

import { createServer } from "node:http";
import { envFromProcess, requireUserFrom } from "@forculus/sdk/node";
createServer(async (req, res) => {
try {
const user = await requireUserFrom(req, envFromProcess());
res.end(`hello ${user.email}\n`);
} catch {
res.statusCode = 401;
res.end("unauthorized\n");
}
}).listen(process.env.PORT);

requireUserFrom takes anything with Node-style headers, or the headers themselves. envFromProcess() reads the three variables from process.env.

A request with two tokens is refused. The edge sends exactly one, so a second was added by something else. Node’s own server joins a repeated header into one value, so under node:http, Express and Koa two tokens fail as invalid identity token.

A delivery on a verified public path arrives with a token of kind webhook. requireWebhook checks it and says which provider signed the delivery. requireUser refuses a webhook’s token, and requireWebhook refuses a person’s.

import { requireWebhook, Unauthorized } from "@forculus/sdk";
export default {
async fetch(request, env) {
if (new URL(request.url).pathname === "/hooks/stripe") {
try {
const hook = await requireWebhook(request, env, { provider: "stripe" });
const event = await request.json();
// hook.delivery is Stripe's event id. Skip one you've handled.
return new Response(null, { status: 204 });
} catch (err) {
if (err instanceof Unauthorized) return err.toResponse();
throw err;
}
}
// requireUser for everything else
},
};

provider is required: stripe, github, slack, shopify, standard_webhooks or hmac_sha256, or a list of them, like { provider: ["github", "stripe"] }. A delivery from any other provider is refused, and so is every delivery when the list is empty. It returns a Webhook:

interface Webhook {
provider: "stripe" | "github" | "slack" | "shopify" | "standard_webhooks" | "hmac_sha256";
pattern: string; // the public path it came in on, like "/hooks/stripe"
delivery: string | null; // the provider's signed id, null where it signs none
signedAt: number | null; // unix seconds, null where the provider signs no time
workspaceId: string;
}

The body arrives byte for byte as the provider sent it, so request.json() or your framework’s body parser reads it as usual.

Mount the webhook route before any middleware that checks every request for a person. That middleware would refuse the delivery.

import express from "express";
import { requireUser, requireWebhook } from "@forculus/sdk/express";
app.post("/hooks/stripe", express.json(), requireWebhook({ provider: "stripe" }), (req, res) => {
// req.webhook, req.body
res.sendStatus(204);
});
app.use(requireUser());

requireWebhook() answers 401 unauthorized to anything but a verified delivery from the providers it names, and otherwise sets req.webhook.

import { getWebhook, forculus, webhook } from "@forculus/sdk/hono";
app.post("/hooks/stripe", webhook({ provider: "stripe" }), async (c) => {
const hook = getWebhook(c);
const event = await c.req.json();
return c.body(null, 204);
});
app.use("*", forculus());

webhook() answers 401 itself, and reads the variables we set the way forculus() does. getWebhook(c) throws if webhook() didn’t run on that route.

In a route handler, such as app/hooks/stripe/route.ts:

import { Unauthorized } from "@forculus/sdk";
import { requireWebhook } from "@forculus/sdk/next";
export async function POST(request: Request) {
try {
const hook = await requireWebhook({ provider: "stripe" });
} catch (err) {
if (err instanceof Unauthorized) return err.toResponse();
throw err;
}
const event = await request.json();
return new Response(null, { status: 204 });
}

It reads the headers through next/headers, or from headers if you pass it: requireWebhook({ provider: "stripe", headers: request.headers }).

import { envFromProcess, requireWebhookFrom } from "@forculus/sdk/node";
const hook = await requireWebhookFrom(req, envFromProcess(), { provider: "github" });

Like requireUserFrom, it refuses a token sent twice.

verifyIdentity(token, env) runs the same checks on a token you took from somewhere else, such as a WebSocket message:

import { verifyIdentity } from "@forculus/sdk";
const user = await verifyIdentity(token, env);

verifyWebhook(token, env, { provider: "stripe" }) does the same for a webhook’s token.

Every refusal is an Unauthorized (status 401) or a Forbidden (status 403). Both have .toResponse(), which answers unauthorized or forbidden: this needs editor as plain text with Cache-Control: no-store.

The message says why, for your logs:

err.message Why
no identity token The request has no X-Forculus-Identity header
more than one identity token The headers given to /node or /express hold X-Forculus-Identity as an array. Node’s own server never does this, so there two tokens give invalid identity token
invalid identity token The token failed a check
couldn't fetch the identity keys The key set couldn’t be fetched, and there’s no fresh copy
FORCULUS_APP_ID, FORCULUS_IDENTITY_ISSUER and FORCULUS_JWKS_URL must be set The variables didn’t reach the SDK
FORCULUS_IDENTITY_ISSUER and FORCULUS_JWKS_URL must use https, except under forculus dev (FORCULUS_DEV=1, on this machine) The issuer or key set is http, and FORCULUS_DEV isn’t 1. Only forculus dev uses http. Pass FORCULUS_DEV with the others if you build env yourself
pass headers() in, or call this where next/headers can be imported /next was called where next/headers isn’t available
this needs editor A Forbidden from requireRole(user, "editor")
not a webhook this route takes From requireWebhook: a provider the route doesn’t name signed the delivery
invalid webhook token From requireWebhook: the token’s webhook claims failed a check
no provider named From requireWebhook: the call named no provider, so it refuses everything

A person’s token given to requireWebhook, or a webhook’s given to requireUser, fails as invalid identity token, because its audience is wrong for that call.

Log the message, but don’t send it to the caller. The 401 or 403 is enough.

forculus dev runs your app on your machine behind a copy of our front door, with real tokens. See Local development.

Terminal window
forculus dev -- npm run dev