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

bash
# .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 ONLY

Add 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.

tsx
// 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 from the incoming request with cookies() from next/headers. In Next.js 15/16, cookies() is async — await it.

ts
// 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.

ts
// 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.

ts
// 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.

ts
// 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.

ts
// 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

Type to search…

↑↓ navigate open esc close