
Insforge Integrations
- 263 installs
- 36 repo stars
- Updated August 4, 2026
- insforge/insforge-skills
This is a copy of insforge-integrations by insforge - installs and ranking accrue to the original listing.
Connect InsForge apps to external providers—auth, webhooks, queues, storage, and SaaS APIs—using approved patterns instead of ad hoc SDK glue across services.
About
Covers InsForge integration recipes: authenticate to providers, map external payloads to InsForge models, configure webhooks and sync jobs, and standardize error handling for multi-service SaaS or agent backends.
- Third-party connector patterns
- Webhook and event flows
- Credential and scope handling
- InsForge adapter setup
- Cross-service orchestration
Insforge Integrations by the numbers
- 263 all-time installs (skills.sh)
- +10 installs in the week ending Aug 2, 2026 (Skillselion tracking)
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/insforge/insforge-skills --skill insforge-integrationsAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 263 |
|---|---|
| repo stars | ★ 36 |
| Last updated | August 4, 2026 |
| Repository | insforge/insforge-skills ↗ |
What it does
Connect InsForge apps to external providers—auth, webhooks, queues, storage, and SaaS APIs—using approved patterns instead of ad hoc SDK glue across services.
Files
InsForge Integrations
This skill covers integrating third-party providers with InsForge. Currently two categories are supported: auth providers (RLS via JWT claims) and payment facilitators (x402 HTTP payment protocol). Each provider has its own guide under this directory.
Auth Providers
| Provider | Guide | When to use |
|---|---|---|
| Clerk | Clerk JWT Templates + InsForge RLS | Clerk signs tokens directly via JWT Template — no server-side signing needed |
| Auth0 | Auth0 Actions + InsForge RLS | Auth0 uses a post-login Action to embed claims into the access token |
| WorkOS | WorkOS AuthKit + InsForge RLS | WorkOS AuthKit middleware + server-side JWT signing with jsonwebtoken |
| Kinde | Kinde + InsForge RLS | Kinde token customization for InsForge integration |
| Stytch | Stytch + InsForge RLS | Stytch session tokens for InsForge integration |
| Better Auth | Better Auth + InsForge RLS | Self-hosted auth running in your InsForge Postgres — no third-party SaaS, no per-MAU cost |
Payment Facilitators
| Provider | Guide | When to use |
|---|---|---|
| OKX x402 | OKX as x402 facilitator (USDG on X Layer) | Pay-per-use HTTP endpoints settled onchain with zero gas for the payer |
Common Patterns
Auth providers
1. Provider signs or issues a JWT containing the user's ID 2. JWT is passed to InsForge via accessToken in createClient() (deprecated alias: edgeFunctionToken) 3. InsForge exposes claims through auth.jwt() in SQL 4. RLS policies use a requesting_user_id() function to enforce row-level security
Payment facilitators (x402)
1. Server returns `402 Payment Required` with a JSON challenge base64-encoded in PAYMENT-REQUIRED header 2. Client signs an EIP-3009 authorization using the stablecoin's EIP-712 domain 3. Server forwards the signed payload to the facilitator's /verify + /settle endpoints 4. Server records the settled payment in an InsForge table with a realtime trigger for live dashboards
Choosing a Provider
Auth
- Clerk — Simplest setup; JWT Template handles signing, no server code needed
- Auth0 — Flexible; uses post-login Actions for claim injection
- WorkOS — Enterprise-focused; AuthKit middleware + server-side JWT signing
- Kinde — Developer-friendly; built-in token customization
- Stytch — API-first; session-based token flow
- Better Auth — Self-hosted in your Postgres; no SaaS vendor; you own the user table. Pairs cleanly with InsForge's Postgres via a connection string + a small bridge route. Requires a one-time
REVOKEafter migrate to seal PostgREST exposure.
Payment facilitators
- OKX x402 — Onchain pay-per-use via USDG on X Layer; zero gas for the payer
Setup
1. Identify which provider the project uses 2. Read the corresponding reference guide from the tables above 3. Follow the provider-specific setup steps
Usage Examples
Each provider guide includes full code examples for:
- Provider dashboard configuration (API keys, application settings, etc.)
- Server and client code (JWT utilities for auth; facilitator client + signing utilities for payments)
- Database setup (RLS for auth; payment table + realtime trigger for payments)
- Environment variable setup
Refer to the specific references/<provider>.md file for complete examples.
Best Practices
Auth
- All auth provider user IDs are strings (not UUIDs) — always use
TEXTcolumns foruser_id - Use
requesting_user_id()instead ofauth.uid()for RLS policies - Pass the JWT via
accessToken— a static string, not a function; for short-lived tokens (Clerk) sync refreshes withclient.setAccessToken() - Always get the JWT secret via
npx @insforge/cli secrets get JWT_SECRET
Payment facilitators (x402)
- Always check the result of the database
insert(...)after settlement — settlement takes money onchain before the insert runs; a silent DB failure loses the record - Add
UNIQUEto thetx_hashcolumn to prevent duplicate records from retries - Verify EIP-712 domain (
name,version) against the token contract's on-chainDOMAIN_SEPARATOR— wrong values produceInvalid Authorityerrors - Use a
MOCK_OKX_FACILITATORenv flag for local dev so the full flow can be exercised without real funds
Common Mistakes
Auth
| Mistake | Solution |
|---|---|
Using auth.uid() for RLS | Use requesting_user_id() — third-party IDs are strings, not UUIDs |
Using UUID columns for user_id | Use TEXT — all supported providers use string-format IDs |
| Hardcoding the JWT secret | Always retrieve via npx @insforge/cli secrets get JWT_SECRET |
Missing requesting_user_id() function | Must be created before RLS policies will work |
Payments (x402)
| Mistake | Solution |
|---|---|
| Using an OKX exchange trading API key | Create a separate Web3 API key at web3.okx.com/onchainos/dev-portal |
| Wrong EIP-712 domain values | Read the token contract's DOMAIN_SEPARATOR — for USDG on X Layer use name: "Global Dollar", version: "1" |
| Ignoring DB insert error after settlement | Always destructure { error } and log/handle it — money has already moved |
MOCK_OKX_FACILITATOR=true in production | Mock mode is demo-only; it returns fake tx hashes and bypasses verification |
InsForge + Auth0 Integration Guide
Auth0 signs an InsForge-compatible JWT inside a Post Login Action, embeds it as a custom claim on the ID token, and the Next.js app extracts it to pass to the InsForge client as accessToken (deprecated alias: edgeFunctionToken). InsForge validates the token and uses the sub claim for Row Level Security.
Key packages
@auth0/nextjs-auth0— Auth0 SDK for Next.js (use v4+)@insforge/sdk— InsForge client
Recommended Workflow
1. Create Auth0 application → Auth0 Dashboard (manual)
2. Create/link InsForge project → npx @insforge/cli create or link
3. Create Post Login Action → Auth0 Dashboard (manual, paste code below)
4. Install deps + configure env → npm install, .env.local
5. Set up Auth0 client → lib/auth0.ts with beforeSessionSaved
6. Set up middleware + layout → middleware.ts, app/layout.tsx
7. Create InsForge client utility → lib/insforge.ts
8. Set up InsForge database → requesting_user_id() + table + RLS
9. Build features → CRUD pages using InsForge clientDashboard setup (manual, cannot be automated)
Auth0 Application
- Create a Regular Web Application in Auth0 Dashboard > Applications
- Set Allowed Callback URLs to
http://localhost:3000/auth/callback - Set Allowed Logout URLs to
http://localhost:3000 - Note down Domain, Client ID, Client Secret
Auth0 Post Login Action
- Create in Auth0 Dashboard > Actions > Library > Build Custom
- Name:
Generate InsForge Token, trigger: Post Login - Add
jsonwebtokenas a dependency in the action editor - Add
INSFORGE_JWT_SECRETin the action's Secrets tab - Deploy the action and drag it into the post-login trigger flow
InsForge Project
- Create via
npx @insforge/cli createor link vianpx @insforge/cli link --project-id <id> - Get the JWT secret via CLI:
npx @insforge/cli secrets get JWT_SECRET - Note down URL and Anon Key from InsForge, then store the CLI value in Auth0 as
INSFORGE_JWT_SECRET
Auth0 Post Login Action
This code runs in Auth0's environment (not your app). The action must sign a JWT with the InsForge secret and attach it as a namespaced custom claim on the ID token.
const jwt = require('jsonwebtoken');
exports.onExecutePostLogin = async (event, api) => {
const insforgeToken = jwt.sign(
{
sub: event.user.user_id,
role: 'authenticated',
aud: 'insforge-api',
email: event.user.email,
},
event.secrets.INSFORGE_JWT_SECRET,
{ expiresIn: '1h' }
);
api.idToken.setCustomClaim('https://insforge.dev/insforge_token', insforgeToken);
};Auth0 v4 SDK — beforeSessionSaved
Auth0 v4 SDK filters out custom claims from the ID token by default. You must configure beforeSessionSaved on Auth0Client to extract the InsForge token into the session. Without this, getSession().user will not contain the token.
The `idToken` parameter is a raw JWT string, not a decoded object — you must split and base64url-decode it:
// lib/auth0.ts
beforeSessionSaved: async (session, idToken) => {
if (idToken) {
const parts = idToken.split(".");
const payload = JSON.parse(Buffer.from(parts[1], "base64url").toString());
const insforgeToken = payload["https://insforge.dev/insforge_token"];
if (insforgeToken) {
(session.user ??= {})["https://insforge.dev/insforge_token"] = insforgeToken;
}
}
return session;
}Middleware
- Auth0 v4 uses
auth0.middleware()exported directly frommiddleware.tsasexport const middleware = auth0.middleware() - No
app/api/auth/[auth0]/route.jsneeded in v4 - Match paths:
/auth/:path*and any protected routes
// middleware.ts
import { auth0 } from "@/lib/auth0";
export const middleware = auth0.middleware();
export const config = {
matcher: ["/auth/:path*", "/protected/:path*"],
};Layout
- Wrap the app with
Auth0Providerfrom@auth0/nextjs-auth0/clientinapp/layout.tsx
import { Auth0Provider } from '@auth0/nextjs-auth0/client';
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
<Auth0Provider>{children}</Auth0Provider>
</body>
</html>
);
}InsForge client
- Create a utility at
lib/insforge.tsthat callsauth0.getSession(), reads the token fromsession.user["https://insforge.dev/insforge_token"], and passes it asaccessTokentocreateClient
// lib/insforge.ts
import { createClient } from '@insforge/sdk';
import { auth0 } from '@/lib/auth0';
export async function createInsForgeClient() {
const session = await auth0.getSession();
const insforgeToken = session?.user?.["https://insforge.dev/insforge_token"];
return createClient({
baseUrl: process.env.NEXT_PUBLIC_INSFORGE_URL,
accessToken: insforgeToken,
});
}Database setup
- Auth0 user IDs are strings (e.g.
auth0|64a...), not UUIDs — useTEXTcolumns foruser_id - Create a
requesting_user_id()SQL function that extracts thesubclaim fromauth.jwt()as text - Set
user_idcolumn default torequesting_user_id()so it auto-populates on insert - Enable RLS and create policies that compare
user_id = requesting_user_id()
create or replace function public.requesting_user_id()
returns text
language sql stable
as $$
select nullif(auth.jwt() ->> 'sub', '')::text
$$;Environment variables
| Variable | Source |
|---|---|
AUTH0_SECRET | Generate with openssl rand -hex 32 |
APP_BASE_URL | http://localhost:3000 |
AUTH0_DOMAIN | Auth0 Dashboard |
AUTH0_CLIENT_ID | Auth0 Dashboard |
AUTH0_CLIENT_SECRET | Auth0 Dashboard |
NEXT_PUBLIC_INSFORGE_URL | InsForge Dashboard |
NEXT_PUBLIC_INSFORGE_ANON_KEY | InsForge Dashboard |
INSFORGE_JWT_SECRET | InsForge CLI (npx @insforge/cli secrets get JWT_SECRET) |
Common Mistakes
| Mistake | Solution |
|---|---|
❌ Forgetting beforeSessionSaved | ✅ Always configure it — without it the InsForge token is silently dropped |
❌ Treating idToken as a decoded object | ✅ It's a raw JWT string — split and base64url-decode the payload |
❌ Using auth.uid() for RLS policies | ✅ Use requesting_user_id() — Auth0 IDs are strings, not UUIDs |
❌ Creating app/api/auth/[auth0]/route.js | ✅ Not needed in v4 — auth0.middleware() handles it |
InsForge + Better Auth Integration Guide
Better Auth is the only supported auth provider that runs inside your own Postgres database — there is no third-party SaaS in the loop. You point Better Auth at InsForge's Postgres via a connection string, it creates user / session / account / verification tables in a dedicated better_auth schema (hidden from PostgREST by construction — InsForge exposes only public), and a small bridge route on your app server signs an HS256 JWT for the InsForge HTTP API. Better Auth's id column is a string (not UUID), the same convention every other third-party integration here uses for user_id.
Three schemas, three owners — the integration boundary is structural, not procedural:
auth.* ← InsForge platform internals (project admins, OAuth) — untouched
better_auth.* ← Better Auth's tables (this integration)
public.* ← your app's data, with cross-schema FK to better_auth.user(id)This guide covers two framework setups in detail:
- Next.js (App Router) — same-origin, fullstack; the easy path
- Vite + React (or any standalone React SPA) — needs a small Node server somewhere to host BA's routes; covered in its own section
The auth/bridge primitives are framework-agnostic — lib/auth.ts, the schema setup, RLS policies, plugins, and the useInsforgeClient hook are identical across both. Only the route-handler shape and a few env-var prefixes differ.
Recommended Workflow
For Next.js apps, the InsForge CLI scaffolds every file in this guide in one command:
npx @insforge/cli link --auth better-auth # or create --auth better-auth for a fresh dir
npm install
npm run setup # creates better_auth schema, runs BA migrate, sets up notes + RLS
npm run devThe scaffold is overlay-safe — existing files are preserved, package.json is deep-merged, and env collisions are auto-resolved.
The rest of this guide is the reference layer: what each scaffolded file looks like, why it's shaped that way, how to extend it (plugins, custom claims, magic-link, two-factor), and how to run on non-Next stacks. Read the section that matches what you're customizing — you don't need to read top-to-bottom unless you're integrating manually.
Integrating manually (no CLI, or non-Next stack)? Sequence: (1)npx @insforge/cli createorlink, (2)npx @insforge/cli secrets get JWT_SECRET, (3) install deps and fill.env.local, (4) writelib/auth.tswithsearch_pathset tobetter_auth, public(so BA's tables go in the dedicated schema), (5)CREATE SCHEMA better_auth, (6)npx @better-auth/cli migrate, (7) BA route handler, (8) bridge route, (9)requesting_user_id()+ RLS +notestable FK'd tobetter_auth.user(id), (10)useInsforgeClient(or server-sidecreateInsForgeClient), (11) feature pages. Each numbered step has its own section below.
Starting point: npx @insforge/cli link --auth better-auth (or create) scaffolds a working Next 15 + BA project. The --auth flag is canonical; the rest of this guide explains the pieces it generates. For Vite/React or other non-Next stacks, see Vite / React-only setups below — the proxy config and bridge route map directly.
Key packages
better-auth— Better Auth core@better-auth/cli— fornpx @better-auth/cli migratepg— Postgres driver (Better Auth wraps this)jsonwebtoken+@types/jsonwebtoken— server-side JWT signing for the bridge@insforge/sdk— InsForge client
Dashboard setup (manual, cannot be automated)
InsForge Project
- Create via
npx @insforge/cli createor link vianpx @insforge/cli link --project-id <id> - Get the JWT secret:
npx @insforge/cli secrets get JWT_SECRET— used to sign the bridge JWT - Get the Postgres connection string for Better Auth's pool — for self-hosted InsForge, the docker-compose exposes
POSTGRES_PORT(default5432) with the project'sPOSTGRES_USER/POSTGRES_PASSWORD/POSTGRES_DB - Note Base URL and Anon Key from the InsForge dashboard
Better Auth
No SaaS dashboard. Better Auth runs entirely in your code + your Postgres.
Better Auth configuration
// lib/auth.ts
import { betterAuth } from 'better-auth';
import { Pool } from 'pg';
// Fail at module-load if a required var is missing. Better than `!`
// because the error names the missing var instead of crashing on a
// downstream undefined. Used for server-side env vars throughout this
// guide. Client-side `NEXT_PUBLIC_*` reads keep the `!` syntax — those
// are inlined at build time, so a module-load check would just fire in
// the browser at request time anyway.
function requireEnv(name: string): string {
const v = process.env[name];
if (!v) throw new Error(`Missing required env var: ${name}`);
return v;
}
// BA's tables live in the dedicated `better_auth` schema. PostgREST exposes
// only `public` by default, so this isolation is what keeps user emails out
// of the data API — no REVOKE step needed. `pg.Pool` doesn't take a `schema`
// option, so we set search_path on every new pooled connection. BA's CLI
// (`better-auth migrate`) imports this file, so search_path applies during
// migrate too — its `CREATE TABLE`s land in `better_auth.*`, not `public`.
const pool = new Pool({ connectionString: requireEnv('DATABASE_URL') });
pool.on('connect', (client) => {
client.query('SET search_path TO better_auth, public').catch(() => { /* noop */ });
});
export const auth = betterAuth({
database: pool,
emailAndPassword: { enabled: true },
secret: requireEnv('BETTER_AUTH_SECRET'), // Better Auth's own session secret — different from InsForge's JWT_SECRET
baseURL: requireEnv('BETTER_AUTH_URL'), // e.g. http://localhost:3000
});// lib/auth-client.ts
import { createAuthClient } from 'better-auth/react';
export const authClient = createAuthClient({
baseURL: process.env.NEXT_PUBLIC_BETTER_AUTH_URL!,
});Schema setup + first migrate
Create the schema once, then run BA's migrate — it'll create its four tables in better_auth.* because of the search_path set in lib/auth.ts:
-- Run BEFORE auth:migrate so the schema exists when BA tries CREATE TABLE.
CREATE SCHEMA IF NOT EXISTS better_auth;npx @better-auth/cli migrate --config ./lib/auth.ts -yCreates better_auth.user, better_auth.session, better_auth.account, better_auth.verification. Idempotent — re-run any time you add additionalFields.
Why this is enough. PostgREST is configured to expose only thepublicschema (PGRST_DB_SCHEMAS=public). Anything inbetter_authis invisible to the data API, so anon and authenticated SDK calls return404 relation "public.user" does not existinstead of leaking emails. No REVOKE step. The InsForge dashboard reachesbetter_auth.*through its admin route (postgres superuser pool, role-independent), so Studio inspection works.
What about future plugins? BA plugins that add tables (organization,twoFactor,apiKey,passkey, …) write to whatever schema BA's pool sees insearch_path— i.e.,better_auth. They inherit the same isolation automatically. No per-plugin REVOKE template needed.
Better Auth route handlers (Next.js)
// app/api/auth/[...all]/route.ts
import { auth } from '@/lib/auth';
import { toNextJsHandler } from 'better-auth/next-js';
export const { POST, GET } = toNextJsHandler(auth);For Vite/React or other non-Next setups (Hono, Express, Fastify, Bun), see Vite / React-only setups.
The bridge route (Next.js)
This is where the integration lives. Better Auth's own jwt() plugin issues asymmetric tokens (EdDSA/ES256/RS256) which InsForge's PostgREST cannot verify — it expects HS256 signed with the InsForge JWT secret. So we re-sign:
// app/api/insforge-token/route.ts
import { auth } from '@/lib/auth';
import jwt from 'jsonwebtoken';
import { headers } from 'next/headers';
import { NextResponse } from 'next/server';
export async function GET() {
const session = await auth.api.getSession({ headers: await headers() });
if (!session?.user) {
return NextResponse.json({ error: 'not signed in' }, { status: 401 });
}
// Mint the smallest claim set InsForge needs. Don't add email or other PII —
// RLS reads sub via auth.jwt() ->> 'sub' and that's all that matters.
const token = jwt.sign(
{
sub: session.user.id,
role: 'authenticated',
aud: 'insforge-api',
},
requireEnv('INSFORGE_JWT_SECRET'),
{ algorithm: 'HS256', expiresIn: '1h' },
);
// no-store: bridge tokens are short-lived and per-session — never cache.
return NextResponse.json(
{ token },
{ headers: { 'Cache-Control': 'no-store' } },
);
}Same shape and claims as the WorkOS / Auth0 / Kinde / Stytch guides — only difference is the session is read from Better Auth instead of a SaaS provider. The non-Next equivalent (Hono / Express) is in Vite / React-only setups.
InsForge client
Two patterns, same as the existing five guides. Pattern A is the default; Pattern B is for SSR-heavy apps.
Pattern A — long-lived client + imperative refresh (SPA / client components)
Same shape as the Clerk integration. Better Auth's useSession() provides reactive sign-in/sign-out state. Framework-agnostic React — works identically in Next.js client components and standalone Vite/CRA/etc apps; only the env-var accessor differs (process.env.NEXT_PUBLIC_* vs import.meta.env.VITE_*).
// lib/insforge.ts
'use client'; // Next.js — drop this directive in Vite / non-Next setups
import { createClient, type InsForgeClient } from '@insforge/sdk';
import { authClient } from './auth-client';
import { useEffect, useMemo, useState } from 'react';
const REFRESH_INTERVAL_MS = 50 * 60 * 1000; // 50 min for a 1h bridge JWT
// Bridge JWT → both HTTP and realtime auth.
// SDK ≥ 1.3.0: client.setAccessToken(token) updates the HTTP client AND the
// realtime token manager in one call (pass null to clear on sign-out). Skipping
// realtime is the classic bug — the WebSocket keeps using the anon key and
// senderId shows the anon UUID instead of the Better Auth id.
// On SDK < 1.3.0 the public method doesn't exist — see the legacy fallback below.
export function useInsforgeClient(): { client: InsForgeClient; isReady: boolean } {
const session = authClient.useSession();
const [isReady, setIsReady] = useState(false);
const client = useMemo(
() =>
createClient({
baseUrl: process.env.NEXT_PUBLIC_INSFORGE_BASE_URL!,
anonKey: process.env.NEXT_PUBLIC_INSFORGE_ANON_KEY!,
autoRefreshToken: false,
}),
[],
);
useEffect(() => {
if (!session.data?.user) {
client.setAccessToken(null);
setIsReady(false);
return;
}
let cancelled = false;
const refresh = async () => {
try {
const res = await fetch('/api/insforge-token', { credentials: 'same-origin' });
if (!res.ok) throw new Error(`bridge ${res.status}`);
const { token } = await res.json();
if (cancelled) return;
if (typeof token !== 'string' || !token) throw new Error('bridge: no token in response');
client.setAccessToken(token);
setIsReady(true);
} catch {
if (cancelled) return;
client.setAccessToken(null);
setIsReady(false);
}
};
void refresh();
const id = setInterval(() => void refresh(), REFRESH_INTERVAL_MS);
return () => {
cancelled = true;
clearInterval(id);
};
}, [client, session.data?.user?.id]);
return { client, isReady };
}SDK version note.client.setAccessToken(token)is public as of SDK 1.3.0 and updates both the HTTP client and the realtimeTokenManagerin one call. On older SDKs (< 1.3.0) the public method doesn't exist — it lives on the internalTokenManager, and you must update both the HTTP client and realtime manually with this fallback helper:
// Legacy fallback for SDK < 1.3.0 only — on 1.3.0+ call client.setAccessToken(token) directly.
function setBridgeToken(client: InsForgeClient, token: string | null) {
client.getHttpClient().setAuthToken(token);
(client.realtime as unknown as { tokenManager: { setAccessToken: (t: string | null) => void } })
.tokenManager.setAccessToken(token);
}On SDK < 1.3.0, also replace each client.setAccessToken(token) / client.setAccessToken(null) call in the hook above with setBridgeToken(client, token) / setBridgeToken(client, null) — dropping in the helper alone isn't enough. Updating only the HTTP client leaves the realtime WebSocket on the anon key, so senderId shows the anon UUID instead of the user's Better Auth id.
Pattern B — per-request client construction (server components, route handlers)
Same shape as the WorkOS / Auth0 / Kinde / Stytch guides. Use this in RSC or server actions.
// lib/insforge.server.ts
import { createClient } from '@insforge/sdk';
import { auth } from '@/lib/auth';
import { headers } from 'next/headers';
import jwt from 'jsonwebtoken';
export async function createInsForgeClient() {
const session = await auth.api.getSession({ headers: await headers() });
if (!session?.user) return null;
const insforgeToken = jwt.sign(
{
sub: session.user.id,
role: 'authenticated',
aud: 'insforge-api',
},
requireEnv('INSFORGE_JWT_SECRET'),
{ algorithm: 'HS256', expiresIn: '1h' },
);
return createClient({
baseUrl: process.env.NEXT_PUBLIC_INSFORGE_BASE_URL!,
accessToken: insforgeToken,
});
}Sign-out
Better Auth sign-out doesn't clear the InsForge SDK's in-memory token. Pattern A handles this automatically via the useEffect cleanup; if you sign out outside of React, do it explicitly with client.setAccessToken(null) (clears both HTTP and realtime in one call):
await authClient.signOut();
client.setAccessToken(null); // SDK ≥ 1.3.0 — clears HTTP + realtimeOn SDK < 1.3.0 the public method doesn't exist; clear both manually instead:
await authClient.signOut();
client.getHttpClient().setAuthToken(null);
// tokenManager is private at compile-time, accessible at runtime — cast to reach it.
(client.realtime as unknown as { tokenManager: { setAccessToken: (t: string | null) => void } })
.tokenManager.setAccessToken(null);Database setup
Better Auth user IDs are strings (e.g. f5kGYiUXDPEJqRDQ4jgtNTopIzpj5MgK), not UUIDs. Use TEXT for any FK referencing them, and FK to better_auth.user(id) — never to auth.users(id) (InsForge's separate native auth table, UUID id, irrelevant to BA).
-- 0. ensure gen_random_uuid() is available (idempotent)
CREATE EXTENSION IF NOT EXISTS pgcrypto;
-- 1. helper that extracts sub claim from auth.jwt()
CREATE OR REPLACE FUNCTION public.requesting_user_id()
RETURNS text
LANGUAGE sql STABLE
AS $$
SELECT NULLIF(auth.jwt() ->> 'sub', '')::text
$$;
-- 2. example: a notes table owned by Better Auth users
-- All statements below are rerun-safe so this script can be applied repeatedly
-- (Postgres has no CREATE POLICY IF NOT EXISTS through PG17, so DROP IF EXISTS first).
CREATE TABLE IF NOT EXISTS public.notes (
id text PRIMARY KEY DEFAULT gen_random_uuid()::text,
user_id text NOT NULL DEFAULT public.requesting_user_id()
REFERENCES better_auth."user"(id) ON DELETE CASCADE,
body text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
ALTER TABLE public.notes ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS notes_owner_select ON public.notes;
CREATE POLICY notes_owner_select ON public.notes
FOR SELECT TO authenticated
USING (user_id = public.requesting_user_id());
DROP POLICY IF EXISTS notes_owner_insert ON public.notes;
CREATE POLICY notes_owner_insert ON public.notes
FOR INSERT TO authenticated
WITH CHECK (user_id = public.requesting_user_id());
DROP POLICY IF EXISTS notes_owner_update ON public.notes;
CREATE POLICY notes_owner_update ON public.notes
FOR UPDATE TO authenticated
USING (user_id = public.requesting_user_id())
WITH CHECK (user_id = public.requesting_user_id());
DROP POLICY IF EXISTS notes_owner_delete ON public.notes;
CREATE POLICY notes_owner_delete ON public.notes
FOR DELETE TO authenticated
USING (user_id = public.requesting_user_id());
GRANT USAGE ON SCHEMA public TO authenticated;
GRANT SELECT, INSERT, UPDATE, DELETE ON public.notes TO authenticated;
NOTIFY pgrst, 'reload schema';Prefer running this through the InsForge CLI (npx @insforge/cli db migrations new ... && npx @insforge/cli db migrations up) — the CLI emits the NOTIFY automatically. Raw psql works but you must send the notify yourself or PostgREST returns 404 {} until next reload.
Realtime (optional)
If you use client.realtime, two extra one-time setup steps are needed because Better Auth IDs are strings (not UUIDs) and InsForge realtime currently requires both manual channel registration and a column-type fix.
-- 1. Allow string sender_ids (matches the rest of the third-party convention)
ALTER TABLE realtime.messages ALTER COLUMN sender_id TYPE text;
-- 2. Register a channel pattern (admin-only operation; do this once)
INSERT INTO realtime.channels (pattern, description, enabled)
VALUES ('chat:%', 'app chat channels', TRUE)
ON CONFLICT (pattern) DO NOTHING;The channel pattern uses SQL LIKE syntax — chat:% matches chat:lobby, chat:dm:user_xyz, etc.
client.setAccessToken(token) (Pattern A) propagates the token to the realtime TokenManager as well as the HTTP client — that's why we call it on every refresh, not just when realtime is in use. Pattern B (createClient({ accessToken: ... })) handles both automatically because the SDK pipes the config token into the TokenManager at construction time (edgeFunctionToken is the deprecated alias).
After the SQL fixes above: a two-user realtime broadcast verifies end-to-end — senderId on the received message equals the publisher's Better Auth id.
Email transport (verification + password reset)
Better Auth invokes sendVerificationEmail and sendResetPassword callbacks on signup and reset flows. Wire those callbacks to InsForge's client.emails.send() so all transactional mail goes through one provider.
// lib/auth.ts
import { betterAuth } from 'better-auth';
import { createClient } from '@insforge/sdk';
import jwt from 'jsonwebtoken';
import { Pool } from 'pg';
// Per-call helper — BA callbacks fire server-side without an end-user JWT,
// so mint a short-lived service-style HS256 token signed with the SAME
// secret that the bridge route uses. Reusing INSFORGE_JWT_SECRET keeps the
// trust boundary minimal.
function insforgeServerClient() {
const token = jwt.sign(
{ sub: 'better-auth-service', role: 'authenticated', aud: 'insforge-api' },
requireEnv('INSFORGE_JWT_SECRET'),
{ algorithm: 'HS256', expiresIn: '5m' },
);
const c = createClient({ baseUrl: process.env.NEXT_PUBLIC_INSFORGE_BASE_URL! });
c.getHttpClient().setAuthToken(token);
return c;
}
export const auth = betterAuth({
database: new Pool({ connectionString: requireEnv('DATABASE_URL') }),
secret: requireEnv('BETTER_AUTH_SECRET'),
baseURL: requireEnv('BETTER_AUTH_URL'),
emailAndPassword: {
enabled: true,
requireEmailVerification: true,
sendResetPassword: async ({ user, url }) => {
const insforge = insforgeServerClient();
const { error } = await insforge.emails.send({
to: user.email,
subject: 'Reset your password',
html: `<p>Click <a href="${url}">here</a> to reset.</p>`,
});
if (error) throw new Error(error.message);
},
},
emailVerification: {
sendOnSignUp: true,
sendVerificationEmail: async ({ user, url }) => {
const insforge = insforgeServerClient();
const { error } = await insforge.emails.send({
to: user.email,
subject: 'Verify your email',
html: `<p>Hi ${user.name ?? ''}, click <a href="${url}">here</a> to verify.</p>`,
});
if (error) throw new Error(error.message);
},
},
});Where InsForge actually sends from
client.emails.send calls POST /api/email/send-raw. InsForge resolves the provider per-call:
1. SMTP — if you set SMTP credentials via PUT /api/auth/smtp-config (admin token), every send goes through your SMTP server. 2. Cloud fallback — if no SMTP is configured, InsForge tries its managed cloud relay. Requires PROJECT_ID (set automatically on cloud-hosted projects; missing on self-hosted).
For self-hosted dev, configure SMTP first or you'll get INTERNAL_ERROR: PROJECT_ID is not configured. The /api/auth/smtp-config PUT validates and rejects loopback / private addresses as an SSRF guard, so for a local maildev/mailpit you need a non-loopback hostname (e.g. a .local record on your LAN, or expose maildev publicly via ngrok).
Why a service token, not the bridge route
The bridge route (/api/insforge-token) is for end-user requests — it reads BA's session cookie and signs a JWT with sub = user.id. But sendVerificationEmail runs before the user has a session (during signup). A 5-minute service-token JWT signed with INSFORGE_JWT_SECRET clears the auth check at /api/email/send-raw and is the equivalent of a "service role" call.
Better Auth plugins (optional)
Better Auth ships ~37 plugins. Most are drop-in (twoFactor, magicLink, username) and require no InsForge-side changes. Plugins that add tables create them in better_auth automatically — the pool's search_path is already scoped to that schema, so new tables inherit the same isolation as the core four. No per-plugin REVOKE template, no per-plugin lockdown step.
Organization plugin
Adds five tables (organization, team, member, teamMember, invitation) and two columns on session (activeOrganizationId, activeTeamId):
// lib/auth.ts
import { organization } from 'better-auth/plugins';
export const auth = betterAuth({
// ...
plugins: [
organization({ teams: { enabled: true } }),
],
});Re-run npx @better-auth/cli migrate -y — that's it. The new tables land in better_auth.organization, better_auth.team, better_auth.member, better_auth.teamMember, better_auth.invitation. Verify with curl http://<insforge>/organization?select=id (anon) — should return 404 relation "public.organization" does not exist because PostgREST never sees the schema.
For multi-tenant RLS on app tables, add org_id as a custom JWT claim by reading session.activeOrganizationId in the bridge route:
// app/api/insforge-token/route.ts (delta)
const token = jwt.sign(
{
sub: session.user.id,
role: 'authenticated',
aud: 'insforge-api',
org_id: session.session.activeOrganizationId ?? null, // ← add
},
requireEnv('INSFORGE_JWT_SECRET'),
{ algorithm: 'HS256', expiresIn: '1h' },
);Then in policies use auth.jwt() ->> 'org_id' alongside requesting_user_id().
Other table-adding plugins
| Plugin | Tables added (all in better_auth schema) |
|---|---|
twoFactor | twoFactor |
apiKey | apikey |
passkey | passkey |
oidcProvider | oauthApplication, oauthAccessToken, oauthConsent |
Rule of thumb: after every auth migrate, \dt better_auth.* to see what BA created. No follow-up REVOKE step — search_path did the isolation for you.
Vite / React-only setups
A pure React SPA (Vite, CRA, RSPack, …) has no built-in server, so you need a small Node/Bun process to host BA's /api/auth/* routes plus the bridge route. Two well-trodden patterns:
| Pattern | When to use | Origin model |
|---|---|---|
| A. Vite proxy → Next.js / Hono / Express | Already have (or want) a backend; cleanest for local dev | Same-origin to the browser (proxy hides the server) |
| B. Cross-origin React + standalone BA server | True microservice split; multi-domain prod | Different origins; needs explicit CORS + cookie config |
Pattern A — Vite proxy
vite.config.ts proxies /api to the BA server. To the browser everything is on :5173, so the BA cookie is auto-attached to /api/insforge-token with no CORS dance.
// vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:3030', // your BA / bridge server
changeOrigin: true,
configure: (proxy) => {
// Rewrite Origin so BA's CSRF check (which compares the request's
// Origin against its `baseURL`) sees its own URL. Without this,
// sign-out (and any other state-changing endpoint that enforces
// Origin) returns 403 because the browser's Origin is :5173, not
// :3030. Sign-up has looser Origin handling, which is why this
// bug only shows up later in the flow and is easy to miss.
proxy.on('proxyReq', (proxyReq) => {
proxyReq.setHeader('origin', 'http://localhost:3030');
});
},
},
},
},
});Equivalent without the rewrite: add trustedOrigins: ['http://localhost:5173'] to betterAuth({...}). Either approach works; the proxy rewrite keeps lib/auth.ts unchanged.
The bridge route in non-Next servers
The Next.js route handler from earlier maps directly. Hono on Bun:
// server.ts (Hono — also works on Node via @hono/node-server, or Bun.serve)
import { Hono } from 'hono';
import { auth } from './lib/auth';
import jwt from 'jsonwebtoken';
const app = new Hono();
// 1. Better Auth catch-all — equivalent of toNextJsHandler
app.on(['POST', 'GET'], '/api/auth/*', (c) => auth.handler(c.req.raw));
// 2. Bridge route — exact same JWT shape as the Next version (sub/role/aud only,
// no PII; see lines 138–148 above for why).
app.get('/api/insforge-token', async (c) => {
const session = await auth.api.getSession({ headers: c.req.raw.headers });
if (!session?.user) return c.json({ error: 'not signed in' }, 401);
const token = jwt.sign(
{
sub: session.user.id,
role: 'authenticated',
aud: 'insforge-api',
},
requireEnv('INSFORGE_JWT_SECRET'),
{ algorithm: 'HS256', expiresIn: '1h' },
);
return c.json({ token }, 200, { 'Cache-Control': 'no-store' });
});
export default { port: 3030, fetch: app.fetch };Express is the same shape — app.all('/api/auth/*', toNodeHandler(auth)) (use better-auth/node instead of better-auth/next-js), then a regular app.get('/api/insforge-token', ...) handler. The JWT signing block is identical.
Env vars in Vite
Vite exposes import.meta.env.VITE_* instead of process.env.NEXT_PUBLIC_*. Anything server-only (DATABASE_URL, BETTER_AUTH_SECRET, INSFORGE_JWT_SECRET) reads from process.env in the BA server process — same as Next.
# .env.local (Vite project)
VITE_BETTER_AUTH_URL=http://localhost:5173 # the SPA origin (or BA origin if cross-origin)
VITE_INSFORGE_BASE_URL=http://localhost:7130
VITE_INSFORGE_ANON_KEY=<from InsForge dashboard>lib/auth-client.ts and lib/insforge.ts then read import.meta.env.VITE_BETTER_AUTH_URL etc. The Pattern A hook code shown earlier is otherwise identical.
Pattern B — true cross-origin (no proxy)
If you really want the React app and the BA server on different origins (e.g., app.example.com and auth.example.com), four changes vs the same-origin path:
1. BA cookie config: advanced: { defaultCookieAttributes: { sameSite: 'none', secure: true } } so the cookie crosses origins. secure: true requires HTTPS — localhost counts as a secure context in Chrome/Firefox/Safari, so this works in dev too. 2. `trustedOrigins` on the BA config: trustedOrigins: ['https://app.example.com']. Without this, BA's CSRF check rejects POSTs from the SPA with 403 (sign-out, etc. — sign-up is more lenient and won't fail, masking the issue). 3. Bridge route CORS: Access-Control-Allow-Credentials: true and an explicit Access-Control-Allow-Origin: <app origin> (not *). Plus a preflight OPTIONS handler. 4. Client fetch: fetch('/api/insforge-token', { credentials: 'include' }) — without it the BA cookie isn't sent.
Forget any of the four and the bridge silently sees no session.
Environment variables
Server-only vars (read via process.env in the BA process) are the same across both setups. Browser-exposed vars use NEXT_PUBLIC_* in Next.js and VITE_* in Vite.
| Variable | Source | Where read |
|---|---|---|
DATABASE_URL | InsForge Postgres connection string | Server (BA's Pool) |
BETTER_AUTH_SECRET | random — openssl rand -base64 32 | Server (BA session signing) |
BETTER_AUTH_URL | your BA server URL | Server (baseURL in betterAuth()) |
INSFORGE_JWT_SECRET | npx @insforge/cli secrets get JWT_SECRET | Server (bridge route HS256 signing) |
NEXT_PUBLIC_BETTER_AUTH_URL (Next.js) / VITE_BETTER_AUTH_URL (Vite) | same as BETTER_AUTH_URL (or the SPA origin if proxying) | Browser (authClient baseURL) |
NEXT_PUBLIC_INSFORGE_BASE_URL / VITE_INSFORGE_BASE_URL | InsForge dashboard | Browser (SDK baseUrl) |
NEXT_PUBLIC_INSFORGE_ANON_KEY / VITE_INSFORGE_ANON_KEY | InsForge dashboard | Browser (SDK anonKey) |
Common Mistakes
| Mistake | Solution |
|---|---|
❌ Forgetting NOTIFY pgrst, 'reload schema' after raw psql DDL | ✅ PostgREST returns 404 {} until reloaded. Use the InsForge CLI for migrations and the notify happens automatically. |
❌ Using Better Auth's jwt() plugin directly with InsForge | ✅ It issues asymmetric (EdDSA/ES256/RS256) tokens; InsForge's PostgREST verifies HS256. Use the bridge route instead. |
❌ Using auth.uid() for RLS policies | ✅ Use requesting_user_id() — Better Auth IDs are strings, not UUIDs. |
❌ FK'ing to auth.users(id) (or public.user(id)) | ✅ FK to better_auth.user(id) — that's where Better Auth puts its tables. auth.users is InsForge's native auth (UUID id, irrelevant here); public.user no longer exists post-migrate. |
❌ Forgetting CREATE SCHEMA better_auth before the first auth:migrate | ✅ The migrate fails with "schema better_auth does not exist". The CLI scaffold's npm run setup runs schema → migrate → app SQL in order; if you migrate manually, create the schema first. |
❌ Setting search_path only inside the BA pool but FK'ing app tables to a schema-unqualified "user" | ✅ Outside BA's pool, the Postgres default search_path doesn't include better_auth. Always qualify FKs explicitly: REFERENCES better_auth."user"(id). |
❌ Re-using BETTER_AUTH_SECRET as the InsForge JWT secret | ✅ They are independent. BETTER_AUTH_SECRET is for Better Auth's session cookies; INSFORGE_JWT_SECRET is the HS256 key for the bridge JWT. |
| ❌ Setting the token only once on mount (Pattern A) | ✅ Refresh on a ~50min interval for a 1h JWT, keyed on Better Auth's useSession(). |
❌ Forgetting credentials: 'same-origin' (or 'include' cross-origin) on the bridge fetch | ✅ Without credentials, the Better Auth cookie isn't sent and the bridge always returns 401. |
❌ Cross-origin without sameSite: 'none'; secure on the BA cookie | ✅ The browser drops the cookie on cross-origin requests by default. Configure Better Auth's cookies for cross-origin explicitly. |
❌ Missing Origin header on direct fetch/curl to Better Auth POSTs | ✅ Better Auth requires Origin for CSRF. Browsers send it automatically; server-side clients must add 'Origin: <baseURL>'. |
❌ Realtime client shows senderId as the anon UUID instead of the user's BA id (Pattern A only) | ✅ On SDK ≥ 1.3.0 call client.setAccessToken(token) — it updates the HTTP client and the realtime token manager together. (On SDK < 1.3.0 the public method doesn't exist; use the setBridgeToken legacy fallback from Pattern A, which updates the HTTP client plus the private realtime tokenManager via a cast.) Pattern B's accessToken already pipes into both. |
❌ Realtime publish silently fails for authenticated users (UNAUTHORIZED) | ✅ realtime.messages.sender_id is uuid in core InsForge; Better Auth IDs are strings. One-time fix: ALTER TABLE realtime.messages ALTER COLUMN sender_id TYPE text; |
| ❌ Vite SPA proxying to a separate BA server, sign-out (or any state-changing endpoint) returns 403 | ✅ BA's CSRF check compares the request's Origin against its baseURL. Either rewrite the proxy's Origin header to BA's URL (Vite proxy.configure) or add the SPA origin to BA's trustedOrigins. Sign-up has looser handling and won't trip this — the bug shows up later. |
❌ Cross-origin missing trustedOrigins even with sameSite: 'none'; secure: true | ✅ Cookie config alone isn't enough — BA's CSRF gate also reads trustedOrigins. Add the SPA's full origin (no trailing slash) to the array. |
InsForge + Clerk Integration Guide (Next.js)
Clerk signs tokens with InsForge's JWT secret directly via a JWT Template — no server-side signing needed. The app calls getToken({ template: 'insforge' }) and forwards the token to the InsForge client via client.setAccessToken().
This guide targets Next.js (App Router). The same pattern works in other React setups, but all examples and env vars assume Next.js.
Key packages
@clerk/nextjs— Clerk SDK for Next.js (includesclerkMiddleware,ClerkProvider, hooks, and prebuilt<SignIn>/<SignUp>components)@insforge/sdk— InsForge client
Recommended Workflow
1. Create Clerk application → Clerk Dashboard (manual)
2. Create/link InsForge project → npx @insforge/cli create or link
3. Create JWT template in Clerk → Clerk Dashboard (manual)
4. Install deps + configure env → npm install, .env.local
5. Wire ClerkProvider + middleware → app/layout.tsx + middleware.ts
6. Initialize InsForge client → createClient + setAccessToken with Clerk token (refresh on interval)
7. Set up InsForge database → requesting_user_id() + table + RLS
8. Build features → CRUD pages using InsForge clientDashboard setup (manual, cannot be automated)
Clerk Application
- Create an application in Clerk Dashboard
- Note down Publishable Key and Secret Key
Clerk JWT Template
- Create in Clerk Dashboard > Configure > JWT Templates > New template > Blank
- Name:
insforge - Signing algorithm:
HS256 - Signing key: the InsForge JWT Secret
- Claims:
{ "role": "authenticated", "aud": "insforge-api" } - Do NOT add
suboriss— they are reserved and auto-included
InsForge Project
- Create via
npx @insforge/cli createor link vianpx @insforge/cli link --project-id <id> - Get the JWT secret via CLI:
npx @insforge/cli secrets get JWT_SECRET - Note down URL and Anon Key from InsForge, then use the CLI output as the signing key in Clerk
Next.js wiring
middleware.ts (project root)
import { clerkMiddleware } from '@clerk/nextjs/server';
export default clerkMiddleware();
export const config = {
matcher: [
'/((?!_next|[^?]*\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico|csv|docx?|xlsx?|zip|webmanifest)).*)',
'/(api|trpc)(.*)',
],
};app/layout.tsx
Wrap the app in <ClerkProvider> (it's a server component — no 'use client' needed).
import { ClerkProvider } from '@clerk/nextjs';
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<ClerkProvider>
<html lang="en">
<body>{children}</body>
</html>
</ClerkProvider>
);
}Sign-in / sign-up routes
Use Clerk's optional catch-all routes so Clerk's internal redirects work:
app/sign-in/[[...sign-in]]/page.tsx
app/sign-up/[[...sign-up]]/page.tsx// app/sign-in/[[...sign-in]]/page.tsx
import { SignIn } from '@clerk/nextjs';
export default function Page() {
return <SignIn path="/sign-in" signUpUrl="/sign-up" forceRedirectUrl="/" />;
}InsForge client
- Create the client once with
createClient({ baseUrl, anonKey }) - Use Clerk's
useAuth()to getgetToken - In a
useEffectkeyed onisSignedIn, callgetToken({ template: 'insforge' })and pipe the result intoclient.setAccessToken(token) - Clerk JWT templates default to 60-second expiry — refresh the token on a ~50-second interval while the user is signed in; clear the token on sign-out
- The template name
'insforge'must match the Clerk dashboard exactly @insforge/sdk'saccessTokenconfig field (deprecated alias:edgeFunctionToken) is a static string, not a function — it cannot auto-refresh on its own, which is why we useclient.setAccessToken()imperatively (it updates the HTTP client and the realtime token manager together)- This hook uses Clerk hooks, so the file must start with
'use client'
// lib/insforge.ts
'use client';
import { createClient, type InsForgeClient } from '@insforge/sdk';
import { useAuth } from '@clerk/nextjs';
import { useEffect, useMemo, useState } from 'react';
const TOKEN_REFRESH_MS = 50_000; // Clerk template tokens expire in 60s by default
export function useInsforgeClient(): { client: InsForgeClient; isReady: boolean } {
const { getToken, isSignedIn } = useAuth();
const [isReady, setIsReady] = useState(false);
const client = useMemo(
() =>
createClient({
baseUrl: process.env.NEXT_PUBLIC_INSFORGE_BASE_URL!,
anonKey: process.env.NEXT_PUBLIC_INSFORGE_ANON_KEY!,
}),
[],
);
useEffect(() => {
if (!isSignedIn) {
client.setAccessToken(null);
setIsReady(false);
return;
}
let cancelled = false;
const refresh = async () => {
try {
const token = await getToken({ template: 'insforge' });
if (cancelled) return;
client.setAccessToken(token ?? null);
setIsReady(!!token);
} catch (err) {
if (cancelled) return;
client.setAccessToken(null);
setIsReady(false);
console.error('Failed to refresh Clerk token for InsForge client', err);
}
};
void refresh();
const id = setInterval(() => void refresh(), TOKEN_REFRESH_MS);
return () => {
cancelled = true;
clearInterval(id);
};
}, [client, getToken, isSignedIn]);
return { client, isReady };
}Database setup
- Clerk user IDs are strings (e.g.
user_2xPnG8KxVQr), not UUIDs — useTEXTcolumns foruser_id - Create a
requesting_user_id()SQL function that extracts thesubclaim fromauth.jwt()as text - Set
user_idcolumn default torequesting_user_id()so it auto-populates on insert - Enable RLS and create policies that compare
user_id = requesting_user_id()
create or replace function public.requesting_user_id()
returns text
language sql stable
as $$
select nullif(auth.jwt() ->> 'sub', '')::text
$$;Environment variables
| Variable | Source | Notes |
|---|---|---|
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY | Clerk Dashboard | Exposed to the browser |
CLERK_SECRET_KEY | Clerk Dashboard | Server-only; required by clerkMiddleware() |
NEXT_PUBLIC_INSFORGE_BASE_URL | InsForge Dashboard | Exposed to the browser |
NEXT_PUBLIC_INSFORGE_ANON_KEY | InsForge Dashboard | Exposed to the browser |
Common Mistakes
| Mistake | Solution |
|---|---|
❌ Passing an async function as accessToken | ✅ SDK accepts only a static string there — use client.setAccessToken() instead |
| ❌ Setting the token only once on mount | ✅ Refresh on a ~50s interval — Clerk JWT templates expire in 60s by default |
❌ Adding sub or iss to the JWT template | ✅ These are reserved claims, auto-included by Clerk |
❌ Using auth.uid() for RLS policies | ✅ Use requesting_user_id() — Clerk IDs are strings, not UUIDs |
❌ Omitting CLERK_SECRET_KEY in .env.local | ✅ clerkMiddleware() reads it at runtime — add it alongside the publishable key |
❌ Forgetting 'use client' on lib/insforge.ts | ✅ The hook uses React + Clerk hooks; the file must be a client module |
InsForge + Kinde Integration Guide
Kinde does not support custom JWT signing keys, so you sign a separate JWT server-side using jsonwebtoken. The flow: get the Kinde user from the server session → sign a JWT with InsForge's secret → pass it to InsForge as accessToken (deprecated alias: edgeFunctionToken).
Key packages
@kinde-oss/kinde-auth-nextjs— Kinde SDK for Next.js@insforge/sdk— InsForge clientjsonwebtoken+@types/jsonwebtoken— for server-side JWT signing
Recommended Workflow
1. Create Kinde application → Kinde Dashboard (manual)
2. Create/link InsForge project → npx @insforge/cli create or link
3. Install deps + configure env → npm install, .env.local
4. Create Kinde auth route → app/api/auth/[kindeAuth]/route.js
5. Create InsForge client utility → lib/insforge.ts (server-side JWT signing)
6. Set up InsForge database → requesting_user_id() + table + RLS
7. Build features → CRUD pages using InsForge clientDashboard setup (manual, cannot be automated)
Kinde Application
- Create in Kinde Dashboard > Add application
- Type: Back-end web, SDK: Next.js
- Set Allowed callback URL to
http://localhost:3000/api/auth/kinde_callback - Set Allowed logout redirect URL to
http://localhost:3000 - Enable desired auth methods (Email, Google, etc.) under Authentication
- Note down Domain, Client ID, Client Secret from App Keys
InsForge Project
- Create via
npx @insforge/cli createor link vianpx @insforge/cli link --project-id <id> - Get the JWT secret via CLI:
npx @insforge/cli secrets get JWT_SECRET - Note down URL and Anon Key from InsForge, then export the CLI value as
INSFORGE_JWT_SECRET
Kinde auth route
- Create
app/api/auth/[kindeAuth]/route.jsthat exportshandleAuth()from@kinde-oss/kinde-auth-nextjs/server
// app/api/auth/[kindeAuth]/route.js
import { handleAuth } from "@kinde-oss/kinde-auth-nextjs/server";
export const GET = handleAuth();InsForge client
- Create a server-side utility at
lib/insforge.ts— cannot be used in client components - Use
getKindeServerSession()to getgetUser - Sign a JWT with
jsonwebtokenusingprocess.env.INSFORGE_JWT_SECRET - Required claims:
sub(fromuser.id),role: "authenticated",aud: "insforge-api",email - Set
expiresIn: '1h' - Pass the signed token as
accessTokentocreateClient
// lib/insforge.ts
import { createClient } from '@insforge/sdk';
import { getKindeServerSession } from '@kinde-oss/kinde-auth-nextjs/server';
import jwt from 'jsonwebtoken';
export async function createInsForgeClient() {
const { getUser } = getKindeServerSession();
const user = await getUser();
let accessToken: string | undefined;
if (user) {
accessToken = jwt.sign(
{
sub: user.id,
role: 'authenticated',
aud: 'insforge-api',
email: user.email,
},
process.env.INSFORGE_JWT_SECRET!,
{ expiresIn: '1h' }
);
}
return createClient({
baseUrl: process.env.NEXT_PUBLIC_INSFORGE_URL!,
accessToken,
});
}Database setup
- Kinde user IDs are strings (e.g.
kp_1234abcd), not UUIDs — useTEXTcolumns foruser_id - Create a
requesting_user_id()SQL function that extracts thesubclaim fromauth.jwt()as text - Set
user_idcolumn default torequesting_user_id()so it auto-populates on insert - Enable RLS and create policies that compare
user_id = requesting_user_id()
create or replace function public.requesting_user_id()
returns text
language sql stable
as $$
select nullif(auth.jwt() ->> 'sub', '')::text
$$;Environment variables
| Variable | Source |
|---|---|
KINDE_CLIENT_ID | Kinde Dashboard > App Keys |
KINDE_CLIENT_SECRET | Kinde Dashboard > App Keys |
KINDE_ISSUER_URL | https://YOUR_DOMAIN.kinde.com |
KINDE_SITE_URL | http://localhost:3000 |
KINDE_POST_LOGOUT_REDIRECT_URL | http://localhost:3000 |
KINDE_POST_LOGIN_REDIRECT_URL | http://localhost:3000 |
NEXT_PUBLIC_INSFORGE_URL | InsForge Dashboard |
NEXT_PUBLIC_INSFORGE_ANON_KEY | InsForge Dashboard |
INSFORGE_JWT_SECRET | InsForge CLI (npx @insforge/cli secrets get JWT_SECRET) |
Common Mistakes
| Mistake | Solution |
|---|---|
| ❌ Using Kinde's JWT directly with InsForge | ✅ Kinde doesn't sign with your secret — sign a separate JWT server-side |
| ❌ Using InsForge client in a client component | ✅ getKindeServerSession is server-only — keep the utility server-side |
❌ Using auth.uid() for RLS policies | ✅ Use requesting_user_id() — Kinde IDs are strings, not UUIDs |
InsForge + OKX x402 Payments Integration Guide
OKX acts as the x402 facilitator for the x402 HTTP payment protocol. Your server returns 402 Payment Required with a challenge; the client signs an EIP-3009 TransferWithAuthorization; the server forwards the signed payload to OKX's /verify and /settle endpoints. USDG on X Layer settles with zero gas (OKX pays the gas). Payment records and realtime dashboards live in InsForge.
Key packages
@insforge/sdk— InsForge client for DB writes, AI calls, and realtime subscriptionviem— EIP-712 typed data signing + chain switching on the client- No x402 SDK is required; the facilitator is plain REST
Recommended Workflow
Each step below maps 1:1 to an agent prompt. Run them in order — each step produces concrete files with a verification checkpoint before moving on.
1. Schema & Realtime → migrations/db_init.sql + db import
2. Server Primitives & Env → lib/okx-facilitator.ts, lib/x402.ts, lib/insforge.ts, .env
3. Paid Endpoint & Aggregate → app/api/report + app/api/payments (AI content generator)
4. Client Libs & Consumer Flow → lib/x402-client.ts (incl. chain switch) + fetch→sign→retry state machine
5. Realtime Dashboard → SSR from /api/payments + subscribe x402_payments + try-it + report view
6. Diagnostics & Go-Live → scripts/check-domain.mjs, scripts/check-usdg.mjs, MOCK off, first real paymentPrerequisites (manual, cannot be automated)
OKX Web3 API credentials
- Go to OKX Onchain OS Dev Portal and connect your wallet
- Create a project, then link email + phone (required to enable API key creation)
- Create API Key → save API Key, Secret Key, and the passphrase you set (secret shown only once)
- Do NOT reuse an OKX exchange trading API key — it returns
Invalid Authority(code 50114). The Web3 API is a separate system atweb3.okx.com/onchainos/dev-portal, notokx.com/account/my-api
Payment recipient wallet
- Any EVM address on X Layer (chainId 196) works as the payee
- If using OKX Wallet, copy the 0x-prefixed address (not the
XKO...native format) - Fund the paying wallet (not the recipient) with USDG on X Layer for real settlements — OKX facilitator covers gas
InsForge project
- Create via
npx @insforge/cli createor link vianpx @insforge/cli link --project-id <id> - Get URL, Anon Key, and Service Role Key from dashboard → Project Settings → API Keys
Chain + Asset constants (X Layer)
| Constant | Value |
|---|---|
| Chain ID | 196 (hex 0xc4) |
| CAIP-2 network | eip155:196 |
| USDG contract | 0x4ae46a509f6b1d9056937ba4500cb143933d2dc8 |
| EIP-712 domain name | Global Dollar (NOT "USDG") |
| EIP-712 domain version | 1 (NOT "2") |
| Decimals | 6 |
| RPC | https://rpc.xlayer.tech |
Domain name/version are the most common source of `Invalid Authority` errors. Always run scripts/check-domain.mjs (Step 6) before the first real payment to confirm against the on-chain DOMAIN_SEPARATOR.
Explorer URLs by chain
Render the tx hash in the dashboard as a link so users can verify on-chain:
Chain (stored in payments.chain) | Explorer tx URL pattern |
|---|---|
xlayer | https://www.okx.com/web3/explorer/xlayer/tx/{hash} |
base | https://basescan.org/tx/{hash} |
optimism | https://optimistic.etherscan.io/tx/{hash} |
arbitrum | https://arbiscan.io/tx/{hash} |
// src/lib/explorer.ts
const EXPLORERS: Record<string, string> = {
xlayer: "https://www.okx.com/web3/explorer/xlayer/tx/",
base: "https://basescan.org/tx/",
optimism: "https://optimistic.etherscan.io/tx/",
arbitrum: "https://arbiscan.io/tx/",
};
export function txUrl(chain: string | null, hash: string) {
return (EXPLORERS[chain ?? "xlayer"] ?? EXPLORERS.xlayer) + hash;
}Environment variables
| Variable | Source | Used by |
|---|---|---|
OKX_API_KEY | OKX Onchain OS Dev Portal (Web3 API, NOT exchange API) | server |
OKX_SECRET_KEY | OKX Onchain OS Dev Portal | server |
OKX_PASSPHRASE | Chosen by you when creating the API key | server |
PAYMENT_RECIPIENT | Your EVM wallet address on X Layer (0x...) | server |
NEXT_PUBLIC_INSFORGE_URL | InsForge Dashboard → Project Settings | client + server |
NEXT_PUBLIC_INSFORGE_ANON_KEY | InsForge Dashboard → Project Settings | client |
INSFORGE_SERVICE_KEY | InsForge Dashboard → Project Settings (server-only) | server |
OPENROUTER_API_KEY | InsForge Dashboard → Model Gateway → Overview | server |
MOCK_OKX_FACILITATOR | true for local/demo; unset or false for production | server |
NEXT_PUBLIC_MOCK_OKX_FACILITATOR | Mirror of MOCK_OKX_FACILITATOR if you want the UI to show a "mock mode" badge | client |
Mock mode contract: MOCK_OKX_FACILITATOR=true skips real on-chain verify/settle on the server and returns a random tx hash. The client-side signing flow does not change — the browser still prompts the wallet for a real signature. This keeps the UX identical to production; only the server-side on-chain calls are mocked. Never set MOCK_OKX_FACILITATOR=true in production.
---
Step 1 — Schema & Realtime
Create a single-file migration and apply it with db import. This is idempotent and works for both first-time setup and re-runs in fresh environments.
-- migrations/db_init.sql
-- 1. Payment ledger
create table if not exists x402_payments (
id uuid default gen_random_uuid() primary key,
payer_address text not null,
endpoint text not null,
amount text not null, -- smallest unit (6-decimal)
tx_hash text not null unique, -- UNIQUE prevents duplicate settlement records from retries
chain text default 'xlayer',
status text default 'settled',
response_summary text,
created_at timestamptz default now()
);
create index if not exists idx_x402_payments_payer on x402_payments (payer_address);
create index if not exists idx_x402_payments_created on x402_payments (created_at desc);
-- 2. RLS: public can read the ledger; writes come from the service key only
alter table x402_payments enable row level security;
drop policy if exists public_read on x402_payments;
create policy public_read on x402_payments for select using (true);
-- 3. Realtime channel
insert into realtime.channels (pattern, description, enabled)
values ('x402_payments', 'Payment events for dashboard', true)
on conflict do nothing;
-- 4. Trigger: publish every INSERT to the realtime channel
create or replace function notify_x402_payment()
returns trigger as $$
begin
perform realtime.publish(
'x402_payments',
'INSERT_x402_payments',
jsonb_build_object('new', row_to_json(new))
);
return new;
end;
$$ language plpgsql security definer
set search_path = pg_catalog, public, realtime;
drop trigger if exists x402_payment_realtime on x402_payments;
create trigger x402_payment_realtime
after insert on x402_payments
for each row
execute function notify_x402_payment();Apply:
npx @insforge/cli db import migrations/db_init.sql✓ Verify
npx @insforge/cli db query "select count(*) from x402_payments" --json
# → rowCount: 1, count: "0"
npx @insforge/cli db query "select pattern, enabled from realtime.channels where pattern = 'x402_payments'" --json
# → enabled: true
npx @insforge/cli db query "select tgname from pg_trigger where tgrelid = 'x402_payments'::regclass and not tgisinternal" --json
# → tgname: "x402_payment_realtime"---
Step 2 — Server Primitives & Env
Install deps and create three library files. These have zero coupling to your route handler; you reuse them from any endpoint you want to monetize.
npm install @insforge/sdk viem openai`src/lib/okx-facilitator.ts` — OKX HMAC-signed calls to /verify and /settle, with a MOCK branch for local dev.
import crypto from "crypto";
const OKX_BASE = "https://web3.okx.com/api/v6/x402";
const MOCK = process.env.MOCK_OKX_FACILITATOR === "true";
function signOKX(timestamp: string, method: string, path: string, body: string) {
return crypto
.createHmac("sha256", process.env.OKX_SECRET_KEY!)
.update(timestamp + method + path + body)
.digest("base64");
}
function okxHeaders(method: string, path: string, body: string): Record<string, string> {
const timestamp = new Date().toISOString();
return {
"OK-ACCESS-KEY": process.env.OKX_API_KEY!,
"OK-ACCESS-SIGN": signOKX(timestamp, method, path, body),
"OK-ACCESS-PASSPHRASE": process.env.OKX_PASSPHRASE!,
"OK-ACCESS-TIMESTAMP": timestamp,
"Content-Type": "application/json",
};
}
export async function verifyPayment(paymentPayload: unknown, paymentRequirements: unknown) {
if (MOCK) return { isValid: true, payer: (paymentPayload as any)?.payload?.authorization?.from };
const path = "/api/v6/x402/verify";
const body = JSON.stringify({ x402Version: 1, chainIndex: "196", paymentPayload, paymentRequirements });
const res = await fetch(OKX_BASE + "/verify", { method: "POST", headers: okxHeaders("POST", path, body), body });
const json = await res.json();
return json.data?.[0] ?? { isValid: false, invalidReason: json.msg ?? "unknown" };
}
export async function settlePayment(paymentPayload: unknown, paymentRequirements: unknown) {
if (MOCK) {
const payer = (paymentPayload as any)?.payload?.authorization?.from;
return { success: true, txHash: "0x" + crypto.randomBytes(32).toString("hex"), payer };
}
const path = "/api/v6/x402/settle";
const body = JSON.stringify({ x402Version: 1, chainIndex: "196", syncSettle: true, paymentPayload, paymentRequirements });
const res = await fetch(OKX_BASE + "/settle", { method: "POST", headers: okxHeaders("POST", path, body), body });
const json = await res.json();
return json.data?.[0] ?? { success: false, errorReason: json.msg ?? "unknown" };
}`src/lib/x402.ts` — challenge builder, 402 response, header codecs. All wire-format concerns live here.
const ASSET = "0x4ae46a509f6b1d9056937ba4500cb143933d2dc8"; // USDG on X Layer
export function buildPaymentRequirements(endpointUrl: string) {
return {
scheme: "exact",
maxAmountRequired: "1", // 0.000001 USDG (6 decimals, smallest unit)
resource: endpointUrl,
description: "Premium API endpoint",
mimeType: "application/json",
payTo: process.env.PAYMENT_RECIPIENT ?? "0x0000000000000000000000000000000000000000",
maxTimeoutSeconds: 300,
asset: ASSET,
extra: { name: "Global Dollar", version: "1" }, // EIP-712 domain — verify with scripts/check-domain.mjs
};
}
export function build402Response(paymentRequirements: ReturnType<typeof buildPaymentRequirements>) {
const challenge = { x402Version: 1, accepts: [{ network: "eip155:196", ...paymentRequirements }] };
return new Response(JSON.stringify({ error: "Payment required" }), {
status: 402,
headers: {
"Content-Type": "application/json",
"PAYMENT-REQUIRED": Buffer.from(JSON.stringify(challenge)).toString("base64"),
},
});
}
export function decodePaymentSignature(header: string) {
return JSON.parse(Buffer.from(header, "base64").toString("utf-8"));
}
export function buildPaymentResponseHeader(settlement: { txHash: string; payer: string }) {
return Buffer.from(JSON.stringify({
success: true,
transaction: settlement.txHash,
network: "eip155:196",
payer: settlement.payer,
})).toString("base64");
}`src/lib/insforge.ts` — one file exports both clients so you don't instantiate the SDK twice.
import { createClient } from "@insforge/sdk";
// Server-side: full-privilege client for DB writes, AI calls, aggregate queries
export function createServiceClient() {
return createClient({
baseUrl: process.env.NEXT_PUBLIC_INSFORGE_URL!,
anonKey: process.env.INSFORGE_SERVICE_KEY!, // service key goes through the anonKey slot (Bearer token)
});
}
// Browser-side: anon client for reads (RLS-protected) and realtime subscriptions
export function createBrowserClient() {
return createClient({
baseUrl: process.env.NEXT_PUBLIC_INSFORGE_URL!,
anonKey: process.env.NEXT_PUBLIC_INSFORGE_ANON_KEY!,
});
}`.env` — template with every required variable. Copy from .env.example and fill in.
# OKX Web3 API (Step 1 of prerequisites)
OKX_API_KEY=
OKX_SECRET_KEY=
OKX_PASSPHRASE=
# Payee wallet on X Layer
PAYMENT_RECIPIENT=0x
# InsForge (dashboard → Project Settings → API Keys)
NEXT_PUBLIC_INSFORGE_URL=
NEXT_PUBLIC_INSFORGE_ANON_KEY=
INSFORGE_SERVICE_KEY=
# Model Gateway (dashboard → Model Gateway → Overview → Active OpenRouter key)
OPENROUTER_API_KEY=
# Demo mode — server skips on-chain calls, client still signs normally
MOCK_OKX_FACILITATOR=true
NEXT_PUBLIC_MOCK_OKX_FACILITATOR=true✓ Verify
This check uses Node 20+ --env-file support.
node --env-file=.env -e "['OKX_API_KEY','OKX_SECRET_KEY','OKX_PASSPHRASE','PAYMENT_RECIPIENT','NEXT_PUBLIC_INSFORGE_URL','NEXT_PUBLIC_INSFORGE_ANON_KEY','INSFORGE_SERVICE_KEY','OPENROUTER_API_KEY'].forEach(k => console.log(k, process.env[k] ? 'ok' : 'MISSING'))"
# → every row 'ok'---
Step 3 — Paid Endpoint & Aggregate API
Two server routes:
1. `POST /api/report` — the payment-gated endpoint. Gates the response behind 402, verifies + settles, records the payment, then generates real paid content (an AI-written crypto market report). 2. `GET /api/payments` — dashboard backend. Returns recent rows + aggregate stats using the service key. The dashboard uses this for SSR / initial load so the first paint doesn't wait for the WebSocket.
Paid content shape — the consumer expects this JSON:
{
report: {
title: string;
generated_at: string; // ISO timestamp
model: string; // e.g. "anthropic/claude-sonnet-4.5"
assets: Array<{ symbol: string; price: number; change_24h: number; signal: "bullish" | "bearish" | "neutral" }>;
analysis: string; // markdown
},
payment: { tx_hash: string; payer: string; amount: string }
}`src/app/api/report/route.ts`
import { NextRequest } from "next/server";
import OpenAI from "openai";
import { verifyPayment, settlePayment } from "@/lib/okx-facilitator";
import { createServiceClient } from "@/lib/insforge";
import {
buildPaymentRequirements,
build402Response,
decodePaymentSignature,
buildPaymentResponseHeader,
} from "@/lib/x402";
interface Asset {
symbol: string;
price: number;
change_24h: number;
signal: "bullish" | "bearish" | "neutral";
}
function generateMarketSnapshot(): Asset[] {
const rand = (min: number, max: number) => +(min + Math.random() * (max - min)).toFixed(2);
const signals: Asset["signal"][] = ["bullish", "bearish", "neutral"];
const pick = <T,>(arr: T[]) => arr[Math.floor(Math.random() * arr.length)];
return [
{ symbol: "BTC", price: rand(83000, 86000), change_24h: rand(-3, 5), signal: pick(signals) },
{ symbol: "ETH", price: rand(1600, 1750), change_24h: rand(-4, 4), signal: pick(signals) },
{ symbol: "SOL", price: rand(125, 155), change_24h: rand(-3, 7), signal: pick(signals) },
{ symbol: "AVAX", price: rand(20, 28), change_24h: rand(-5, 6), signal: pick(signals) },
{ symbol: "LINK", price: rand(14, 18), change_24h: rand(-4, 5), signal: pick(signals) },
];
}
async function generateAIReport(assets: Asset[]): Promise<string> {
const openai = new OpenAI({
baseURL: "https://openrouter.ai/api/v1",
apiKey: process.env.OPENROUTER_API_KEY,
});
const marketTable = assets
.map((a) => `- ${a.symbol}: $${a.price.toFixed(2)} (${a.change_24h > 0 ? "+" : ""}${a.change_24h}% 24h, signal: ${a.signal})`)
.join("\n");
const prompt = `You are a crypto market analyst. Write a concise professional market report in markdown based on the following 24h snapshot. Keep it under 250 words.
Snapshot:
${marketTable}
Structure your response as:
## Market Overview
2-3 sentences on overall sentiment and flow.
## Key Movers
Brief commentary on the most notable assets (1-2 lines each).
## Signal Summary
One-line actionable takeaway.
Output ONLY the markdown. No preamble, no disclaimers.`;
try {
const completion = await openai.chat.completions.create({
model: "anthropic/claude-sonnet-4.5",
messages: [{ role: "user", content: prompt }],
temperature: 0.7,
max_tokens: 600,
});
return completion.choices[0]?.message?.content ?? "_AI response was empty._";
} catch (err) {
console.error("[AI] generation failed:", err);
return "_AI service unavailable — falling back to raw data._";
}
}
export async function POST(req: NextRequest) {
const baseUrl = `${req.nextUrl.protocol}//${req.nextUrl.host}`;
const paymentRequirements = buildPaymentRequirements(`${baseUrl}/api/report`);
const paymentSigHeader = req.headers.get("PAYMENT-SIGNATURE");
if (!paymentSigHeader) return build402Response(paymentRequirements);
let paymentPayload: unknown;
try {
paymentPayload = decodePaymentSignature(paymentSigHeader);
} catch {
return Response.json({ error: "Invalid payment signature encoding" }, { status: 400 });
}
const verification = await verifyPayment(paymentPayload, paymentRequirements);
if (!verification.isValid) {
return Response.json({ error: "Payment invalid", reason: verification.invalidReason }, { status: 402 });
}
const settlement = await settlePayment(paymentPayload, paymentRequirements);
if (!settlement.success) {
return Response.json({ error: "Settlement failed", reason: settlement.errorReason }, { status: 500 });
}
// Record payment — ALWAYS check the error; settlement already moved money on-chain.
const insforge = createServiceClient();
const { error: insertError } = await insforge.database.from("x402_payments").insert([{
payer_address: settlement.payer,
endpoint: "/api/report",
amount: paymentRequirements.maxAmountRequired,
tx_hash: settlement.txHash,
status: "settled",
response_summary: "Crypto Market Analysis report",
}]);
if (insertError) console.error("[payment-log] insert failed:", insertError, "tx:", settlement.txHash);
// Generate and return paid content
const assets = generateMarketSnapshot();
const analysis = await generateAIReport(assets);
const report = {
title: "Crypto Market Analysis",
generated_at: new Date().toISOString(),
model: "anthropic/claude-sonnet-4.5",
assets,
analysis,
};
return Response.json(
{ report, payment: { tx_hash: settlement.txHash, payer: settlement.payer, amount: "0.000001 USDG" } },
{ status: 200, headers: { "PAYMENT-RESPONSE": buildPaymentResponseHeader(settlement) } }
);
}`src/app/api/payments/route.ts` — SSR / initial load feeder for the dashboard.
import { createServiceClient } from "@/lib/insforge";
export async function GET() {
const insforge = createServiceClient();
const { data: payments, error } = await insforge.database
.from("x402_payments")
.select()
.order("created_at", { ascending: false })
.limit(50);
if (error) return Response.json({ error: error.message }, { status: 500 });
const { data: allPayments, error: statsError } = await insforge.database
.from("x402_payments")
.select("amount");
if (statsError) return Response.json({ error: statsError.message }, { status: 500 });
const totalRequests = allPayments?.length ?? 0;
const totalRevenue = (allPayments ?? []).reduce(
(sum: number, p: { amount: string }) => sum + Number(p.amount),
0
);
return Response.json({
payments: payments ?? [],
stats: { totalRequests, totalRevenue, latestPayment: payments?.[0]?.created_at ?? null },
});
}✓ Verify (with MOCK_OKX_FACILITATOR=true, server running):
# Expect 402 + PAYMENT-REQUIRED header
curl -sS -i -X POST http://localhost:3000/api/report | head -5
# Craft a fake payload, then expect 200 + paid content + DB row
PAYLOAD='{"x402Version":1,"scheme":"exact","network":"eip155:196","payload":{"signature":"0xdeadbeef","authorization":{"from":"0x1111111111111111111111111111111111111111","to":"'"$PAYMENT_RECIPIENT"'","value":"1","validAfter":"0","validBefore":"9999999999","nonce":"0x'"$(openssl rand -hex 32)"'"}}}'
SIG=$(printf '%s' "$PAYLOAD" | base64)
curl -sS -X POST -H "PAYMENT-SIGNATURE: $SIG" http://localhost:3000/api/report | head -c 400
# Aggregates
curl -sS http://localhost:3000/api/payments
# → { "payments": [...], "stats": { "totalRequests": 1, "totalRevenue": 1, ... } }---
Step 4 — Client Libs & Consumer Flow
Client-side wallet signing (x402-client.ts) with complete X Layer chain switching plus the fetch → sign → retry state machine every consumer component uses.
`src/lib/x402-client.ts`
import { createWalletClient, custom, hexToBigInt, type WalletClient, type Address } from "viem";
const X_LAYER_CHAIN = {
id: 196,
name: "X Layer",
nativeCurrency: { name: "OKB", symbol: "OKB", decimals: 18 },
rpcUrls: { default: { http: ["https://rpc.xlayer.tech"] } },
} as const;
const EIP3009_TYPES = {
TransferWithAuthorization: [
{ name: "from", type: "address" },
{ name: "to", type: "address" },
{ name: "value", type: "uint256" },
{ name: "validAfter", type: "uint256" },
{ name: "validBefore", type: "uint256" },
{ name: "nonce", type: "bytes32" },
],
} as const;
interface PaymentChallenge {
x402Version: number;
accepts: Array<{
network: string;
scheme: string;
maxAmountRequired: string;
resource: string;
description: string;
payTo: string;
maxTimeoutSeconds: number;
asset: string;
extra: { name: string; version: string };
}>;
}
export function decodeChallenge(base64Header: string): PaymentChallenge {
return JSON.parse(atob(base64Header));
}
export async function connectWallet(): Promise<WalletClient> {
if (!window.ethereum) throw new Error("NO_WALLET");
const client = createWalletClient({ chain: X_LAYER_CHAIN, transport: custom(window.ethereum) });
await client.requestAddresses();
// Switch to X Layer if not already on it — otherwise signature verification will fail
const chainId = await client.getChainId();
if (chainId !== 196) {
try {
await window.ethereum.request({
method: "wallet_switchEthereumChain",
params: [{ chainId: "0xc4" }], // 196 in hex
});
} catch (switchError: unknown) {
// 4902 = chain not added to wallet yet
if (
typeof switchError === "object" &&
switchError !== null &&
"code" in switchError &&
(switchError as { code: number }).code === 4902
) {
await window.ethereum.request({
method: "wallet_addEthereumChain",
params: [{
chainId: "0xc4",
chainName: "X Layer",
nativeCurrency: { name: "OKB", symbol: "OKB", decimals: 18 },
rpcUrls: ["https://rpc.xlayer.tech"],
blockExplorerUrls: ["https://www.okx.com/web3/explorer/xlayer"],
}],
});
} else {
throw switchError;
}
}
}
return client;
}
export async function signPayment(challenge: PaymentChallenge, walletClient: WalletClient): Promise<string> {
const accept = challenge.accepts[0];
if (!accept) throw new Error("Invalid challenge: accepts array is empty");
const [account] = await walletClient.getAddresses();
const nonceBytes = crypto.getRandomValues(new Uint8Array(32));
const nonce = ("0x" + Array.from(nonceBytes).map((b) => b.toString(16).padStart(2, "0")).join("")) as `0x${string}`;
const validBefore = BigInt(Math.floor(Date.now() / 1000) + accept.maxTimeoutSeconds);
const authorization = {
from: account,
to: accept.payTo as Address,
value: hexToBigInt(("0x" + BigInt(accept.maxAmountRequired).toString(16)) as `0x${string}`),
validAfter: BigInt(0),
validBefore,
nonce,
};
const signature = await walletClient.signTypedData({
account,
domain: {
name: accept.extra.name, // "Global Dollar"
version: accept.extra.version, // "1"
chainId: 196,
verifyingContract: accept.asset as Address,
},
types: EIP3009_TYPES,
primaryType: "TransferWithAuthorization",
message: authorization,
});
const paymentPayload = {
x402Version: challenge.x402Version,
scheme: accept.scheme,
network: accept.network,
payload: {
signature,
authorization: {
from: account,
to: accept.payTo,
value: accept.maxAmountRequired,
validAfter: "0",
validBefore: validBefore.toString(),
nonce,
},
},
};
return btoa(JSON.stringify(paymentPayload));
}Consumer state machine — the pattern any "Try it" / "Pay" button implements. React-style reducer shown here; port to your framework as needed.
type FlowState =
| { step: "idle" }
| { step: "loading" } // initial fetch
| { step: "payment_required"; challengeHeader: string; body: unknown } // got 402
| { step: "signing" } // wallet prompt open
| { step: "done"; status: number; body: unknown } // success (or non-402 error)
| { step: "error"; message: string };
// 1. Initial call — no payment header
async function tryEndpoint(path: string, setFlow: (s: FlowState) => void) {
setFlow({ step: "loading" });
const res = await fetch(path, { method: "POST" });
const body = await res.json();
if (res.status === 402) {
const challengeHeader = res.headers.get("payment-required");
if (!challengeHeader) return setFlow({ step: "error", message: "Missing challenge header" });
setFlow({ step: "payment_required", challengeHeader, body });
} else {
setFlow({ step: "done", status: res.status, body });
}
}
// 2. User confirms payment → connect wallet → sign → retry with PAYMENT-SIGNATURE
async function confirmPayment(path: string, challengeHeader: string, walletRef: { current: WalletClient | null }, setFlow: (s: FlowState) => void) {
setFlow({ step: "signing" });
try {
if (!walletRef.current) walletRef.current = await connectWallet();
const challenge = decodeChallenge(challengeHeader);
const paymentSignature = await signPayment(challenge, walletRef.current);
setFlow({ step: "loading" });
const res = await fetch(path, {
method: "POST",
headers: { "PAYMENT-SIGNATURE": paymentSignature },
});
setFlow({ step: "done", status: res.status, body: await res.json() });
} catch (err) {
walletRef.current = null; // reset on failure so next attempt re-prompts
const msg = err instanceof Error && err.message === "NO_WALLET"
? "Please install MetaMask or OKX Wallet"
: `Payment failed: ${err instanceof Error ? err.message : String(err)}`;
setFlow({ step: "error", message: msg });
}
}Key details:
- Cache the
WalletClientin a ref so the user isn't prompted to reconnect on every retry. Reset it on any failure. - The
payment-requiredheader is case-insensitive on the fetch response (HTTP is case-insensitive), but send asPAYMENT-SIGNATUREon retries to match what the server'sreq.headers.get("PAYMENT-SIGNATURE")looks for. - In mock mode, the client flow is identical — wallet still prompts for a real EIP-3009 signature. Only the server skips the on-chain calls.
✓ Verify — open the try-it UI (Step 5) in a browser and click the button; the wallet should prompt for a signature on X Layer, and a new row should appear in x402_payments.
---
Step 5 — Realtime Dashboard
The dashboard combines three feeds into one UI:
1. Initial data — fetched once from GET /api/payments (gives stats + last 50 rows without waiting for WebSocket) 2. Realtime stream — subscribe to the x402_payments channel, listen for INSERT_x402_payments, merge into state 3. Try-it playground — the consumer state machine from Step 4, renders the paid report on success
Browser SDK singleton (avoid creating multiple WebSocket clients):
// src/lib/insforge-browser.ts
"use client";
import { createBrowserClient } from "@/lib/insforge";
import type { InsForgeClient } from "@insforge/sdk";
let cached: InsForgeClient | null = null;
export function getBrowserClient(): InsForgeClient {
if (!cached) cached = createBrowserClient();
return cached;
}Realtime subscription pattern:
"use client";
import { useEffect, useState } from "react";
import { getBrowserClient } from "@/lib/insforge-browser";
type Payment = { id: string; payer_address: string; endpoint: string; amount: string; tx_hash: string; chain: string | null; status: string | null; created_at: string };
export function usePayments() {
const [payments, setPayments] = useState<Payment[]>([]);
const [stats, setStats] = useState<{ totalRequests: number; totalRevenue: number; latestPayment: string | null } | null>(null);
const insforge = getBrowserClient();
useEffect(() => {
// 1. Initial load from aggregate route (no WS latency for first paint)
fetch("/api/payments").then((r) => r.json()).then((data) => {
setPayments(data.payments ?? []);
setStats(data.stats ?? null);
});
// 2. Realtime stream
const onInsert = (payload: { new?: Payment }) => {
if (!payload?.new?.id) return;
setPayments((prev) => prev.some((p) => p.id === payload.new!.id) ? prev : [payload.new!, ...prev].slice(0, 50));
setStats((s) => s ? { ...s, totalRequests: s.totalRequests + 1, totalRevenue: s.totalRevenue + Number(payload.new!.amount), latestPayment: payload.new!.created_at } : s);
};
insforge.realtime.on("INSERT_x402_payments", onInsert);
insforge.realtime.connect()
.then(() => insforge.realtime.subscribe("x402_payments"))
.catch(console.error);
return () => {
insforge.realtime.off("INSERT_x402_payments", onInsert);
insforge.realtime.unsubscribe("x402_payments");
};
}, [insforge]);
return { payments, stats };
}UI composition (framework-agnostic outline — see demo source for complete Tailwind components):
<Dashboard>
<FlowSteps /> // 4-step visual: Request → Sign → Settle → Deliver
<ApiPlayground> // Uses Step 4 state machine
<Endpoint card + Try it button>
{flow.step === "payment_required" && <ConfirmPaymentButton />}
{flow.step === "done" && <ReportView body={flow.body} />}
</ApiPlayground>
<StatsCards stats={stats} /> // Total Requests / Total Revenue / Latest
<PaymentLog payments={payments}> // Live table, flash on insert
<tr>
<td>{timeAgo(p.created_at)}</td>
<td>{shortAddr(p.payer_address)}</td>
<td><a href={txUrl(p.chain, p.tx_hash)} target="_blank" rel="noopener noreferrer">{shortAddr(p.tx_hash)}</a></td> {/* Step 0: Explorer URLs */}
<td>{formatAmount(p.amount)}</td>
</tr>
</PaymentLog>
</Dashboard>ReportView — renders the paid content from POST /api/report:
import ReactMarkdown from "react-markdown";
export function ReportView({ body }: { body: { report: any; payment: any } }) {
const { report, payment } = body;
return (
<div>
<h2>{report.title}</h2>
<p>Generated at {report.generated_at} by {report.model}</p>
<table>
{report.assets.map((a: any) => (
<tr key={a.symbol}>
<td>{a.symbol}</td>
<td>${a.price.toFixed(2)}</td>
<td>{a.change_24h > 0 ? "+" : ""}{a.change_24h}%</td>
<td>{a.signal}</td>
</tr>
))}
</table>
<ReactMarkdown>{report.analysis}</ReactMarkdown>
<p>
Paid <strong>{payment.amount}</strong> · tx{" "}
<a href={txUrl("xlayer", payment.tx_hash)} target="_blank" rel="noopener noreferrer">{payment.tx_hash.slice(0, 10)}…</a>
</p>
</div>
);
}✓ Verify
1. Open the dashboard — stats + recent rows should populate within one second (from /api/payments). 2. Click Try it → wallet prompts → confirm signature → response renders the AI-generated report + tx link. 3. The new row should flash into the live log without a page refresh (realtime trigger working). 4. Open a second browser tab with the dashboard; trigger a payment in one — the other tab should update live.
---
Step 6 — Diagnostics & Go-Live
Before removing MOCK_OKX_FACILITATOR=true and accepting real payments, run both diagnostic scripts to confirm your EIP-712 domain and your USDG contract assumptions match on-chain reality. These catch 90% of first-deploy Invalid Authority errors.
`scripts/check-domain.mjs` — compute DOMAIN_SEPARATOR for candidate (name, version) pairs and compare against the on-chain value.
import { keccak256, encodeAbiParameters, stringToBytes } from "viem";
const USDG = "0x4ae46a509f6b1d9056937ba4500cb143933d2dc8";
const CHAIN_ID = 196;
// Read from chain with check-usdg.mjs first, then paste here:
const ON_CHAIN_DOMAIN = "0x415f0706e345fcaf25d5be24c4fd7830d0054fc5742c51a0db9319c759bd3743";
const DOMAIN_TYPEHASH = keccak256(
stringToBytes("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)")
);
function computeDomain(name, version) {
return keccak256(
encodeAbiParameters(
[{ type: "bytes32" }, { type: "bytes32" }, { type: "bytes32" }, { type: "uint256" }, { type: "address" }],
[DOMAIN_TYPEHASH, keccak256(stringToBytes(name)), keccak256(stringToBytes(version)), BigInt(CHAIN_ID), USDG]
)
);
}
const candidates = [
["Global Dollar", "1"], ["Global Dollar", "2"], ["Global Dollar", "v1"], ["Global Dollar", "v2"],
["USDG", "1"], ["USDG", "2"],
];
for (const [name, version] of candidates) {
const hash = computeDomain(name, version);
console.log(`${hash === ON_CHAIN_DOMAIN ? "✓" : "✗"} name="${name}" version="${version}" → ${hash}`);
}
console.log("On-chain:", ON_CHAIN_DOMAIN);`scripts/check-usdg.mjs` — read name(), version(), DOMAIN_SEPARATOR(), and eip712Domain() directly from the USDG contract. Use the output of DOMAIN_SEPARATOR to populate ON_CHAIN_DOMAIN in check-domain.mjs.
import { createPublicClient, http } from "viem";
const RPCS = [
"https://rpc.xlayer.tech",
"https://xlayerrpc.okx.com",
"https://rpc.ankr.com/xlayer",
"https://xlayer-rpc.publicnode.com",
];
let client;
for (const rpc of RPCS) {
try {
const c = createPublicClient({ transport: http(rpc) });
await c.getChainId();
client = c;
console.log("Using RPC:", rpc);
break;
} catch {
console.log("Failed:", rpc);
}
}
if (!client) { console.error("No RPC reachable"); process.exit(1); }
const USDG = "0x4ae46a509f6b1d9056937ba4500cb143933d2dc8";
const abi = [
{ name: "name", type: "function", stateMutability: "view", inputs: [], outputs: [{ type: "string" }] },
{ name: "version", type: "function", stateMutability: "view", inputs: [], outputs: [{ type: "string" }] },
{ name: "DOMAIN_SEPARATOR", type: "function", stateMutability: "view", inputs: [], outputs: [{ type: "bytes32" }] },
{ name: "eip712Domain", type: "function", stateMutability: "view", inputs: [], outputs: [
{ name: "fields", type: "bytes1" }, { name: "name", type: "string" }, { name: "version", type: "string" },
{ name: "chainId", type: "uint256" }, { name: "verifyingContract", type: "address" },
{ name: "salt", type: "bytes32" }, { name: "extensions", type: "uint256[]" },
]},
];
async function tryRead(fnName) {
try {
const result = await client.readContract({ address: USDG, abi, functionName: fnName });
console.log(`✓ ${fnName}:`, result);
} catch (e) {
console.log(`✗ ${fnName}:`, e.shortMessage || e.message?.slice(0, 200));
}
}
await tryRead("name");
await tryRead("version");
await tryRead("DOMAIN_SEPARATOR");
await tryRead("eip712Domain");Run:
node scripts/check-usdg.mjs
# → name: "Global Dollar", version: "1", DOMAIN_SEPARATOR: 0x415f…3743
node scripts/check-domain.mjs
# → ✓ name="Global Dollar" version="1" → 0x415f…3743 (match)Go-live checklist
- [ ]
scripts/check-usdg.mjsprints expectedname/version/DOMAIN_SEPARATOR - [ ]
scripts/check-domain.mjsshows exactly one `✓` — the pair you have inbuildPaymentRequirements.extra - [ ]
MOCK_OKX_FACILITATORunset (orfalse) in prod env - [ ]
NEXT_PUBLIC_MOCK_OKX_FACILITATORunset on client too (no misleading "mock" badge in prod) - [ ] Paying wallet funded with USDG on X Layer (payer pays USDG; OKX facilitator covers gas)
- [ ]
PAYMENT_RECIPIENTis a 0x-prefixed EVM address (not XKO... native format) - [ ] Make one real payment end-to-end: wallet prompts → settles → row in
x402_payments→ tx hash resolves onokx.com/web3/explorer/xlayer/tx/{hash} - [ ] Monitor
postgres.logsand app logs for[payment-log] insert failed— this means money moved but the record is lost; reconcile manually
---
Common Mistakes
| Mistake | Solution |
|---|---|
| ❌ Using OKX exchange trading API key | ✅ Create a separate Web3 API key at web3.okx.com/onchainos/dev-portal |
❌ EIP-712 domain name: "USDG" / version: "2" | ✅ Run scripts/check-domain.mjs; correct values are name: "Global Dollar", version: "1" |
❌ Missing chainIndex: "196" on /verify | ✅ Both /verify and /settle require chainIndex (else 50014 chainIndex not empty or should be numeric) |
| ❌ Wallet on Ethereum mainnet when user clicks pay | ✅ connectWallet() must call wallet_switchEthereumChain → fallback to wallet_addEthereumChain on error 4902 |
❌ Ignoring result of insert(...) after settlement | ✅ Always check { error } — settlement already took money; a silent DB failure loses the record |
❌ tx_hash text not null without UNIQUE | ✅ Add UNIQUE to prevent duplicate records from retries |
❌ Hardcoding xlayer in explorer URL | ✅ Use payment.chain column + txUrl() helper for multi-chain support |
❌ MOCK_OKX_FACILITATOR=true in production | ✅ Demo mode only — removes on-chain guarantee; always unset in prod |
| ❌ Dashboard loads empty then pops | ✅ SSR initial data from /api/payments before subscribing to realtime |
❌ Creating multiple createClient instances in the browser | ✅ Singleton via getBrowserClient() — otherwise duplicate WebSocket connections |
❌ Forgetting RLS on x402_payments | ✅ Enable RLS + public_read SELECT policy; writes go through service key which bypasses RLS |
InsForge + Stytch Integration Guide
Stytch handles authentication via email magic links on the client side. On the server, the Stytch Node SDK validates the session cookie, retrieves the user ID, and signs a JWT with InsForge's secret. The token is passed to InsForge as accessToken (deprecated alias: edgeFunctionToken).
Key packages
@stytch/nextjs+@stytch/vanilla-js— Stytch frontend SDKstytch— Stytch Node SDK (server-side session validation)@insforge/sdk— InsForge clientjsonwebtoken— for server-side JWT signing
Recommended Workflow
1. Configure Stytch project → Stytch Dashboard (manual)
2. Create/link InsForge project → npx @insforge/cli create or link
3. Install deps + configure env → npm install, .env.local
4. Create Stytch provider → app/stytch-provider.tsx (client component)
5. Create login page → app/login/page.tsx with magic links
6. Create auth callback page → app/authenticate/page.tsx (client component, NOT route handler)
7. Create InsForge client utility → lib/insforge.ts (server-side session validation + JWT signing)
8. Set up InsForge database → requesting_user_id() + table + RLS
9. Build features → CRUD pages using InsForge clientDashboard setup (manual, cannot be automated)
Stytch Project
- In Stytch Dashboard > Redirect URLs, add
http://localhost:3000/authenticate(Type: All) - In Frontend SDK > Configuration, add
http://localhost:3000as an authorized domain - Note down Project ID, Public Token, Secret from Project overview > API keys
InsForge Project
- Create via
npx @insforge/cli createor link vianpx @insforge/cli link --project-id <id> - Get the JWT secret via CLI:
npx @insforge/cli secrets get JWT_SECRET - Note down URL and Anon Key from InsForge, then export the CLI value as
INSFORGE_JWT_SECRET
Stytch provider
- Create a
StytchProviderWrapperclient component atapp/stytch-provider.tsxusingcreateStytchUIClientwith the public token - Wrap the app with it in
app/layout.tsx
// app/stytch-provider.tsx
'use client';
import { StytchProvider, createStytchUIClient } from '@stytch/nextjs';
const stytch = createStytchUIClient(
process.env.NEXT_PUBLIC_STYTCH_PUBLIC_TOKEN!
);
export default function StytchProviderWrapper({ children }: { children: React.ReactNode }) {
return <StytchProvider stytch={stytch}>{children}</StytchProvider>;
}Login page
- Create
app/login/page.tsxas a client component - Use
StytchLogincomponent withProducts.emailMagicLinks - Configure redirect URLs to point to
/authenticate
// app/login/page.tsx
'use client';
import { Products, StytchLogin } from '@stytch/nextjs';
export default function Login() {
const config = {
products: [Products.emailMagicLinks],
emailMagicLinksOptions: {
loginRedirectURL: 'http://localhost:3000/authenticate',
loginExpirationMinutes: 30,
signupRedirectURL: 'http://localhost:3000/authenticate',
signupExpirationMinutes: 30,
},
};
return <StytchLogin config={config} />;
}Authentication callback
- Must be a client-side page at
app/authenticate/page.tsx, NOT a route handler — Stytch SDK handles magic link tokens on the client - Use
useStytch().magicLinks.authenticate()to exchange the token - Use a
useRefto prevent double-authentication (React strict mode / re-renders) - Check
stytch_token_type === 'magic_links'from search params before authenticating - Redirect to
/on success,/loginon failure
// app/authenticate/page.tsx
'use client';
import { useStytch, useStytchSession } from '@stytch/nextjs';
import { useRouter, useSearchParams } from 'next/navigation';
import { useEffect, useRef } from 'react';
export default function Authenticate() {
const stytch = useStytch();
const { session } = useStytchSession();
const router = useRouter();
const searchParams = useSearchParams();
const authenticating = useRef(false);
useEffect(() => {
if (session) { router.replace('/'); return; }
const token = searchParams.get('token');
const type = searchParams.get('stytch_token_type');
if (token && type === 'magic_links' && !authenticating.current) {
authenticating.current = true;
stytch.magicLinks
.authenticate(token, { session_duration_minutes: 60 })
.then(() => router.replace('/'))
.catch(() => router.replace('/login'));
}
}, [stytch, session, router, searchParams]);
return <div>Authenticating...</div>;
}InsForge client
- Create a server-side utility at
lib/insforge.ts - Create a Stytch
Clientinstance withproject_idandsecret(useenvs.testfor dev) - Read
stytch_sessionfrom cookies vianext/headers - Validate the session via
stytchClient.sessions.authenticate({ session_token }) - Sign a JWT with
jsonwebtokenusingINSFORGE_JWT_SECRET - Required claims:
sub(fromsession.user_id),role: "authenticated",aud: "insforge-api" - Pass the signed token as
accessTokentocreateClient
// lib/insforge.ts
import { createClient } from '@insforge/sdk';
import jwt from 'jsonwebtoken';
import { Client, envs } from 'stytch';
import { cookies } from 'next/headers';
const stytchClient = new Client({
project_id: process.env.STYTCH_PROJECT_ID!,
secret: process.env.STYTCH_SECRET!,
env: envs.test,
});
export async function createInsForgeClient() {
const cookieStore = await cookies();
const sessionToken = cookieStore.get('stytch_session')?.value;
if (!sessionToken) return null;
const { session } = await stytchClient.sessions.authenticate({
session_token: sessionToken,
});
const insforgeToken = jwt.sign(
{
sub: session.user_id,
role: 'authenticated',
aud: 'insforge-api',
exp: Math.floor(Date.now() / 1000) + 60 * 60,
},
process.env.INSFORGE_JWT_SECRET!
);
return createClient({
baseUrl: process.env.NEXT_PUBLIC_INSFORGE_URL!,
accessToken: insforgeToken,
});
}Database setup
- Stytch user IDs are strings (e.g.
user-test-...), not UUIDs — useTEXTcolumns foruser_id - Create a
requesting_user_id()SQL function that extracts thesubclaim fromauth.jwt()as text - Set
user_idcolumn default torequesting_user_id()so it auto-populates on insert - Enable RLS and create policies that compare
user_id = requesting_user_id()
create or replace function public.requesting_user_id()
returns text
language sql stable
as $$
select nullif(auth.jwt() ->> 'sub', '')::text
$$;Environment variables
| Variable | Source |
|---|---|
STYTCH_PROJECT_ENV | test for dev |
STYTCH_PROJECT_ID | Stytch Dashboard |
NEXT_PUBLIC_STYTCH_PUBLIC_TOKEN | Stytch Dashboard |
STYTCH_SECRET | Stytch Dashboard |
NEXT_PUBLIC_INSFORGE_URL | InsForge Dashboard |
NEXT_PUBLIC_INSFORGE_ANON_KEY | InsForge Dashboard |
INSFORGE_JWT_SECRET | InsForge CLI (npx @insforge/cli secrets get JWT_SECRET) |
Common Mistakes
| Mistake | Solution |
|---|---|
| ❌ Making the callback a route handler | ✅ Must be a client-side page — Stytch SDK handles magic links on the client |
| ❌ Forgetting redirect URL / domain in Stytch dashboard | ✅ Add both http://localhost:3000/authenticate and http://localhost:3000 |
| ❌ Not guarding against double-authentication | ✅ Use a useRef to prevent re-entry on re-renders |
❌ Using auth.uid() for RLS policies | ✅ Use requesting_user_id() — Stytch IDs are strings, not UUIDs |
InsForge + WorkOS Integration Guide
WorkOS AuthKit handles authentication via middleware. On the server, withAuth() retrieves the authenticated user, and a JWT is signed with InsForge's secret containing the user's ID. The token is passed to InsForge as accessToken (deprecated alias: edgeFunctionToken).
Key packages
@workos-inc/authkit-nextjs— WorkOS AuthKit for Next.js@insforge/sdk— InsForge clientjsonwebtoken+@types/jsonwebtoken— for server-side JWT signing
Recommended Workflow
1. Create WorkOS application → WorkOS Dashboard (manual)
2. Configure JWT template → WorkOS Dashboard (manual)
3. Create/link InsForge project → npx @insforge/cli create or link
4. Install deps + configure env → npm install, .env.local
5. Set up callback + login routes → app/callback/route.ts, app/login/route.ts
6. Set up middleware + layout → middleware.ts, app/layout.tsx
7. Create InsForge client utility → lib/insforge.ts (server-side JWT signing)
8. Set up InsForge database → requesting_user_id() + table + RLS
9. Build features → CRUD pages using InsForge clientDashboard setup (manual, cannot be automated)
WorkOS Application
- Note down API Key and Client ID from WorkOS Dashboard > API Keys
- Add
http://localhost:3000/callbackunder Redirects - Enable desired auth methods (email/password, social login, SSO, etc.)
WorkOS JWT Template
- In WorkOS Dashboard > Authentication > Sessions > Configure JWT Template
- Claims:
role: "authenticated",aud: "insforge-api",user_email: {{ user.email }} subis reserved — auto-included, do not add manually
InsForge Project
- Create via
npx @insforge/cli createor link vianpx @insforge/cli link --project-id <id> - Get the JWT secret via CLI:
npx @insforge/cli secrets get JWT_SECRET - Note down URL and Anon Key from InsForge, then export the CLI value as
INSFORGE_JWT_SECRET
App structure
- Callback route:
app/callback/route.ts— exporthandleAuth()from@workos-inc/authkit-nextjs - Layout: wrap with
AuthKitProviderfrom@workos-inc/authkit-nextjs/componentsinapp/layout.tsx - Middleware:
middleware.ts— exportauthkitMiddleware(), match['/', '/api/:path*'] - Login route:
app/login/route.ts— get sign-in URL viagetSignInUrl()andredirect()
Next.js 16 limitation: withAuth({ ensureSignedIn: true }) can cause cookie errors in server components. Use redirect('/login') in the page instead.
// app/callback/route.ts
import { handleAuth } from '@workos-inc/authkit-nextjs';
export const GET = handleAuth();// middleware.ts
import { authkitMiddleware } from '@workos-inc/authkit-nextjs';
export default authkitMiddleware();
export const config = { matcher: ['/', '/api/:path*'] };// app/login/route.ts
import { getSignInUrl } from '@workos-inc/authkit-nextjs';
import { redirect } from 'next/navigation';
export async function GET() {
const signInUrl = await getSignInUrl();
redirect(signInUrl);
}InsForge client
- Create a server-side utility at
lib/insforge.ts - Use
withAuth()to get the WorkOS user - Sign a JWT with
jsonwebtokenusingINSFORGE_JWT_SECRET - Required claims:
sub(fromuser.id),role: "authenticated",aud: "insforge-api" - Set expiration to 1 hour
- Pass the signed token as
accessTokentocreateClient
// lib/insforge.ts
import { createClient } from '@insforge/sdk';
import { withAuth } from '@workos-inc/authkit-nextjs';
import jwt from 'jsonwebtoken';
export async function createInsForgeClient() {
const { user } = await withAuth();
if (!user) return null;
const insforgeToken = jwt.sign(
{
sub: user.id,
role: 'authenticated',
aud: 'insforge-api',
exp: Math.floor(Date.now() / 1000) + 60 * 60,
},
process.env.INSFORGE_JWT_SECRET!
);
return createClient({
baseUrl: process.env.NEXT_PUBLIC_INSFORGE_URL!,
accessToken: insforgeToken,
});
}Database setup
- WorkOS user IDs are strings (e.g.
user_01H...), not UUIDs — useTEXTcolumns foruser_id - Create a
requesting_user_id()SQL function that extracts thesubclaim fromauth.jwt()as text - Set
user_idcolumn default torequesting_user_id()so it auto-populates on insert - Enable RLS and create policies that compare
user_id = requesting_user_id()
create or replace function public.requesting_user_id()
returns text
language sql stable
as $$
select nullif(auth.jwt() ->> 'sub', '')::text
$$;Environment variables
| Variable | Source |
|---|---|
WORKOS_API_KEY | WorkOS Dashboard |
WORKOS_CLIENT_ID | WorkOS Dashboard |
WORKOS_COOKIE_PASSWORD | Generate with openssl rand -hex 32 |
NEXT_PUBLIC_WORKOS_REDIRECT_URI | http://localhost:3000/callback |
NEXT_PUBLIC_INSFORGE_URL | InsForge Dashboard |
NEXT_PUBLIC_INSFORGE_ANON_KEY | InsForge Dashboard |
INSFORGE_JWT_SECRET | InsForge CLI (npx @insforge/cli secrets get JWT_SECRET) |
Common Mistakes
| Mistake | Solution |
|---|---|
❌ Using withAuth({ ensureSignedIn: true }) in server components | ✅ Causes cookie errors on Next.js 16 — use redirect('/login') instead |
❌ Forgetting WORKOS_COOKIE_PASSWORD | ✅ Session encryption fails silently without it |
❌ Using auth.uid() for RLS policies | ✅ Use requesting_user_id() — WorkOS IDs are strings, not UUIDs |