Clerk integration
Bind FormShield's edge verdict and multi-account correlation to your Clerk user ids to catch fake signups and account takeover — framework-agnostic.
Clerk handles your auth UI and session management; FormShield scores the visitor behind it. Drop the beacon on your auth pages so every visitor is scored at the edge, then resolve that visitor from your backend at signup and login and bind the verdict to the Clerk userId. You get a blended IP, email, and browser-reputation verdict plus multi-account correlation tied to each real account.
Clerk’s user.created webhook is server-to-server — it carries no _fs cookie and no browser context (only a coarse originating IP), so by the time it fires the rich fingerprint and automation signals that catch a fake or duplicate account are gone. The beacon captures them at the edge before the form is submitted, so the verdict is ready the moment you bind it to the new user from your backend.
How it fits together
-
Add the beacon site-wide
The beacon scores every visitor at the edge and sets a first-party
_fscookie that identifies the session. It must run on at least your signup and login pages — site-wide is best, so a visitor already has session history before they reach the form. -
Provision a secret key with the sessions scope
The resolve call is secret-key only. Create a key with the
sessionsscope and keep it server-side — it must never reach the browser. -
Bind identity at signup and login
Read the
_fscookie from the incoming request on your server, then callPOST /v1/sessions/resolvewith the ClerkuserIdasexternal_user_id. FormShield binds the verdict to that account so future lookups stay linked even after the cookie is cleared. -
Act on the response
Branch on the blended
verdict.decisionand the multi-accountcorrelationcounts to flag, step up, or block fake and duplicate signups. Keep it best-effort — never block auth on a FormShield outage.
1. Add the beacon site-wide
Load the beacon on every page. It sets a first-party, per-domain cookie named _fs (a signed visitor session id) on the first /v1/collect call, and scores the visitor — IP reputation, email signals, browser fingerprint, automation tells — at the edge.
<script
async
src="https://api.formshield.dev/js/formshield.js"
data-fs-project-key="fs_pub_live_xxx"
data-fs-mode="pageload"
></script>The data-fs-project-key is your publishable key — safe to expose in the browser. Put this tag in your root layout or document shell so it loads once per document across your whole site, including the Clerk sign-up and sign-in pages.
2. Provision a secret key with the sessions scope
In your project’s Settings, create a key with the sessions scope. It looks like fs_live_…. This is a secret key — store it in your server environment (e.g. FORMSHIELD_SECRET_KEY) and never expose it to client code.
3. Bind identity at signup and login
Bind from your backend, in the request context where the visitor’s _fs cookie and real client IP are available — that gives FormShield the strongest markers to correlate (session + IP, plus the fingerprint the beacon already captured). The resolve requires at least one of session_id, fingerprint, or ip — send every marker you have.
Recommended — after-auth server call
In the server handler that runs right after Clerk establishes a session (a post-signup / post-login server action, route handler, or the first authenticated server render), read the _fs cookie and the client IP from the incoming request and resolve with the authenticated Clerk userId. This is the path with the richest markers.
async function bindSession(opts: {
clerkUserId: string
fsCookie: string | undefined // the `_fs` cookie read from the incoming request
ip: string | undefined // client IP (e.g. cf-connecting-ip / x-forwarded-for)
email?: string
kind: "auth.signup" | "auth.login"
}) {
// Resolve needs at least one marker — bail if we have neither cookie nor IP.
if (!opts.fsCookie && !opts.ip) return 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: opts.fsCookie,
ip: opts.ip,
external_user_id: opts.clerkUserId,
email: opts.email,
observation_type: opts.kind,
}),
signal: AbortSignal.timeout(2500),
})
if (!res.ok) return null
return (await res.json()).data
} catch {
return null // best-effort, non-blocking
}
}Pass observation_type: "auth.signup" when an account is created and "auth.login" on subsequent logins. Both bind the same Clerk userId, so the account’s risk history accrues over time.
Alternative — Clerk user.created webhook
A webhook fires for every new account, including ones created outside your own UI. It is server-to-server, so it carries no _fs cookie — but Clerk includes the originating request’s IP at event_attributes.http_request.client_ip. Pass that as ip so the resolve has a marker to bind on (an IP join — coarser than the cookie, but it works):
// After verifying the Svix signature (see Clerk's webhook docs):
async function onClerkUserCreated(evt: {
data: { id: string; email_addresses: { email_address: string }[] }
event_attributes?: { http_request?: { client_ip?: string } }
}) {
const ip = evt.event_attributes?.http_request?.client_ip
if (!ip) return // no marker — nothing to bind on
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({
ip,
external_user_id: evt.data.id, // Clerk userId
email: evt.data.email_addresses[0]?.email_address,
observation_type: "auth.signup",
}),
}).catch(() => null)
if (!res?.ok) return // best-effort: never fail the webhook on FormShield
}4. Act on the response
Every resolve returns the house envelope { version, data, error, metadata }. Read the verdict and the correlation counts together — no single signal decides the outcome.
{
"version": "1",
"data": {
"request_id": "req_a1b2c3d4e5f6",
"bound": true,
"verdict": {
"score": 0.82,
"decision": "review",
"reasons": ["datacenter_ip", "shared_fingerprint"]
},
"continuity": {
"found": true,
"first_seen": 1733500000000,
"last_seen": 1733500120000,
"age_seconds": 120,
"observation_count": 3,
"is_fresh": false
},
"correlation": {
"accounts_sharing_session": 1,
"accounts_sharing_fingerprint": 7,
"accounts_sharing_ip": 4,
"linked_emails": 6
},
"processing_time_ms": 38
},
"error": null,
"metadata": {}
}This visitor came from a datacenter IP and shares a browser fingerprint with six other accounts, so the blended verdict is review. The fake-signup tell is in correlation: seven accounts share this fingerprint and four share the IP — one person spinning up many accounts.
Branch on the decision and the counts:
const data = await bindSession({ /* … */ })
if (!data) return // FormShield unavailable — let auth proceed
const { verdict, correlation } = data
if (verdict.decision === "block" || correlation.accounts_sharing_fingerprint > 10) {
// hard signal — e.g. ban the Clerk user, require manual review
await flagUserForReview(data.request_id)
} else if (verdict.decision === "review" || correlation.accounts_sharing_ip > 5) {
// step up — require email verification, add to a watchlist, rate-limit
await requireStepUpVerification()
}
// else: allow — clean signupverdict.decision is allow, review, or block, fused from IP class (datacenter, VPN, proxy, Tor, residential proxy), email reputation, and browser fingerprint, with the reasons that drove it.
accounts_sharing_fingerprint, accounts_sharing_ip, accounts_sharing_session, and linked_emails — the core fake- and duplicate-signup tell. Invisible per-request, obvious here.
is_fresh plus first/last seen and age. A history-less session arriving straight at signup is weak but useful corroborating evidence of automation.
bound: true means the Clerk userId is linked to this visitor; correlation survives a cleared _fs cookie because the durable join is fingerprint plus IP.
5. Security and reliability notes
Next steps
Copy-pasteable Next.js 15/16 App Router code: next/script, cookies(), Route Handlers, and the webhook route.
The product behind this guide — the full resolve contract, fields, credits, and the /v1/auth/check pre-flight.
The IP reputation lookup feeding the verdict — datacenter, VPN, proxy, Tor, and residential-proxy tags.
Disposable-domain, deliverability, and domain-age checks that feed the email signal.