Next.js + Clerk
Wire the FormShield beacon and the sessions/resolve bind into a Next.js 15/16 App Router app with Clerk — next/script, cookies(), Route Handlers, and the Clerk webhook.
This is the copy-pasteable Next.js App Router version of the Clerk integration guide. You add the beacon with next/script, read the _fs cookie with next/headers, and call POST /v1/sessions/resolve from a Route Handler, a server action wrapped in after(), or the Clerk webhook route — binding the Clerk userId to FormShield’s edge verdict.
Targets Next.js 15/16, the App Router, and @clerk/nextjs. You need a publishable key (fs_pub_live_…) for the beacon and a secret key with the sessions scope (fs_live_…) for the resolve call.
Environment variables
# .env.local
NEXT_PUBLIC_FORMSHIELD_PROJECT_KEY=fs_pub_live_xxx # beacon, safe in the browser
FORMSHIELD_SECRET_KEY=fs_live_xxx # sessions scope — SERVER ONLYAdd the beacon
Load the beacon with next/script using strategy="afterInteractive" in your root layout, so it runs once per document across every page including Clerk’s sign-up and sign-in routes.
// app/layout.tsx
import Script from "next/script"
import { ClerkProvider } from "@clerk/nextjs"
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<ClerkProvider>
<html lang="en">
<body>
{children}
<Script
src="https://api.formshield.dev/js/formshield.js"
strategy="afterInteractive"
data-fs-project-key={process.env.NEXT_PUBLIC_FORMSHIELD_PROJECT_KEY}
data-fs-mode="pageload"
/>
</body>
</html>
</ClerkProvider>
)
}The beacon sets a first-party _fs cookie on its first /v1/collect call and scores the visitor at the edge. strategy="afterInteractive" keeps it off the render path.
Read the _fs cookie server-side
Read the _fs cookie from the incoming request with cookies() from next/headers. In Next.js 15/16, cookies() is async — await it.
// lib/formshield.ts
import { cookies, headers } from "next/headers"
export async function readFsSession(): Promise<string | undefined> {
const cookieStore = await cookies()
return cookieStore.get("_fs")?.value
}
export async function clientIp(): Promise<string | undefined> {
const h = await headers()
return h.get("x-forwarded-for")?.split(",")[0]?.trim()
}The resolve helper
A single best-effort helper both the Route Handler and the webhook can call. It never throws — on any failure it returns null so auth proceeds.
// lib/formshield.ts (continued)
type ResolveInput = {
sessionId: string | undefined
externalUserId: string // Clerk userId
ip?: string
email?: string
observationType: "auth.signup" | "auth.login"
}
export type ResolveData = {
request_id: string
bound: boolean
verdict: { score: number; decision: "allow" | "review" | "block"; reasons: string[] }
continuity: {
found: boolean
first_seen: number
last_seen: number
age_seconds: number
observation_count: number
is_fresh: boolean
}
correlation: {
accounts_sharing_session: number
accounts_sharing_fingerprint: number
accounts_sharing_ip: number
linked_emails: number
}
processing_time_ms: number
}
export async function resolveSession(input: ResolveInput): Promise<ResolveData | null> {
try {
const res = await fetch("https://api.formshield.dev/v1/sessions/resolve", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${process.env.FORMSHIELD_SECRET_KEY}`,
},
body: JSON.stringify({
session_id: input.sessionId,
external_user_id: input.externalUserId,
ip: input.ip,
email: input.email,
observation_type: input.observationType,
}),
signal: AbortSignal.timeout(800), // never block auth on a slow lookup
})
if (!res.ok) return null
const json = (await res.json()) as { data: ResolveData | null }
return json.data
} catch {
return null // best-effort, non-blocking
}
}Bind from a Route Handler
Call this Route Handler from your client right after Clerk establishes a session (post-signup and post-login). It reads the cookie and the current Clerk user server-side, resolves, and acts on the verdict.
// app/api/auth/bind/route.ts
import { NextResponse } from "next/server"
import { auth, currentUser } from "@clerk/nextjs/server"
import { readFsSession, clientIp, resolveSession } from "@/lib/formshield"
export async function POST(request: Request) {
const { userId } = await auth()
if (!userId) return NextResponse.json({ ok: false }, { status: 401 })
const { kind } = (await request.json().catch(() => ({}))) as {
kind?: "auth.signup" | "auth.login"
}
const user = await currentUser()
const data = await resolveSession({
sessionId: await readFsSession(),
externalUserId: userId,
ip: await clientIp(),
email: user?.primaryEmailAddress?.emailAddress,
observationType: kind ?? "auth.login",
})
if (data) {
const { verdict, correlation } = data
if (verdict.decision === "block" || correlation.accounts_sharing_fingerprint > 10) {
await flagUserForReview(userId, data.request_id)
} else if (verdict.decision === "review" || correlation.accounts_sharing_ip > 5) {
await requireStepUpVerification(userId)
}
}
// Always 200 — the bind is advisory and must never block the user.
return NextResponse.json({ ok: true })
}
async function flagUserForReview(_userId: string, _requestId: string) {}
async function requireStepUpVerification(_userId: string) {}Bind from a server action with after()
If you create the account inside a server action, do the resolve in after() so it runs after the response is flushed — zero added latency on the user’s signup.
// app/actions/finish-signup.ts
"use server"
import { after } from "next/server"
import { auth, currentUser } from "@clerk/nextjs/server"
import { readFsSession, clientIp, resolveSession } from "@/lib/formshield"
export async function finishSignup() {
const { userId } = await auth()
if (!userId) return
// Capture request-scoped values now; after() runs post-response.
const sessionId = await readFsSession()
const ip = await clientIp()
const email = (await currentUser())?.primaryEmailAddress?.emailAddress
after(async () => {
const data = await resolveSession({
sessionId,
externalUserId: userId,
ip,
email,
observationType: "auth.signup",
})
if (data && (data.verdict.decision !== "allow" ||
data.correlation.accounts_sharing_fingerprint > 10)) {
// enqueue review / step-up out of band
}
})
}Bind from the Clerk webhook
Clerk’s user.created webhook (delivered via Svix) is the catch-all: it fires for every new account regardless of how it was created. Verify the Svix signature, then resolve with the Clerk userId.
// app/api/webhooks/clerk/route.ts
import { Webhook } from "svix"
import { headers } from "next/headers"
import { resolveSession } from "@/lib/formshield"
export async function POST(request: Request) {
const payload = await request.text()
const h = await headers()
let evt: {
type: string
data: { id: string; email_addresses: { email_address: string }[] }
event_attributes?: { http_request?: { client_ip?: string } }
}
try {
const wh = new Webhook(process.env.CLERK_WEBHOOK_SECRET!)
evt = wh.verify(payload, {
"svix-id": h.get("svix-id")!,
"svix-timestamp": h.get("svix-timestamp")!,
"svix-signature": h.get("svix-signature")!,
}) as typeof evt
} catch {
return new Response("invalid signature", { status: 400 })
}
if (evt.type === "user.created") {
// Server-to-server webhook: no _fs cookie. Clerk includes the originating
// request IP, so bind on that (an IP join). Resolve needs at least one of
// session_id / fingerprint / ip — without the IP there is nothing to bind on.
const ip = evt.event_attributes?.http_request?.client_ip
if (ip) {
const data = await resolveSession({
sessionId: undefined,
ip,
externalUserId: evt.data.id,
email: evt.data.email_addresses[0]?.email_address,
observationType: "auth.signup",
})
if (data && data.verdict.decision !== "allow") {
// flag / review out of band — never block the webhook
}
}
}
return new Response("ok", { status: 200 })
}Reading the response
Every resolve returns { version, data, error, metadata }; the helper above hands you data (typed as ResolveData). The fake-signup tell lives in correlation — counts of accounts sharing a fingerprint, IP, or session — read alongside verdict.decision. See Act on the response for the full payload and branching logic, and Auth Protection for every field and the credit weights.
Next steps
The framework-agnostic version — the full contract, the verdict and correlation fields, and the security model.
The product reference: resolve fields, credits, and the /v1/auth/check pre-flight.
The base Next.js beacon + server reporting guide for pageview tracking.
Capture full requests — UA and IP together — including crawlers and AI bots.