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

  1. Add the beacon site-wide

    The beacon scores every visitor at the edge and sets a first-party _fs cookie 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.

  2. Provision a secret key with the sessions scope

    The resolve call is secret-key only. Create a key with the sessions scope and keep it server-side — it must never reach the browser.

  3. Bind identity at signup and login

    Read the _fs cookie from the incoming request on your server, then call POST /v1/sessions/resolve with the Clerk userId as external_user_id. FormShield binds the verdict to that account so future lookups stay linked even after the cookie is cleared.

  4. Act on the response

    Branch on the blended verdict.decision and the multi-account correlation counts 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.

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

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.

ts
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):

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

json
{
  "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:

ts
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 signup
Blended verdict

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

Multi-account correlation

accounts_sharing_fingerprint, accounts_sharing_ip, accounts_sharing_session, and linked_emails — the core fake- and duplicate-signup tell. Invisible per-request, obvious here.

Session continuity

is_fresh plus first/last seen and age. A history-less session arriving straight at signup is weak but useful corroborating evidence of automation.

Durable binding

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

Type to search…

↑↓ navigate open esc close