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.
npm install @forculus/sdkIt ships ES modules and CommonJS, with TypeScript types. It has no runtime dependencies, because the JWT library it uses is bundled in.
A Worker
Section titled “A Worker”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.
Your framework
Section titled “Your framework”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.
Next.js
Section titled “Next.js”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:
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 }).
Express
Section titled “Express”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.
node:http, Koa and others
Section titled “node:http, Koa and others”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.
Webhooks
Section titled “Webhooks”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.
With Express
Section titled “With Express”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.
With Hono
Section titled “With Hono”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.
With Next.js
Section titled “With Next.js”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 }).
With node:http, Koa and others
Section titled “With node:http, Koa and others”import { envFromProcess, requireWebhookFrom } from "@forculus/sdk/node";
const hook = await requireWebhookFrom(req, envFromProcess(), { provider: "github" });Like requireUserFrom, it refuses a token sent twice.
A token you already have
Section titled “A token you already have”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.
Errors
Section titled “Errors”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.
Run it locally
Section titled “Run it locally”forculus dev runs your app on your machine behind a copy of our front door, with real tokens. See Local development.
forculus dev -- npm run devNext steps
Section titled “Next steps”- SDK overview: every claim in the token, and each check.
- Local development.
- Webhooks and public paths.