URL: /guides/clerk-integration

---
title: Clerk integration
description: 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.

<Note>
  This guide is framework-agnostic. For copy-pasteable Next.js App Router code (`next/script`, `cookies()`, Route Handlers, the webhook route), see [Next.js + Clerk](/guides/nextjs-clerk).
</Note>

## How it fits together

<Steps>
  <Step title="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.
  </Step>

  <Step title="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.
  </Step>

  <Step title="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.
  </Step>

  <Step title="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.
  </Step>
</Steps>

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

<Note>
  Site-wide placement matters: when the beacon has already run on earlier pages, the visitor arrives at your auth form with session continuity. A brand-new, history-less session hitting signup directly is itself a weak automation tell — `continuity.is_fresh`.
</Note>

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

<Warning>
  **Never call `/v1/sessions/resolve` from the browser.** The verdict is anti-tamper precisely because it only ever crosses back to a secret-key server call. A publishable key cannot authenticate this endpoint, and a verdict relayed through client code can be forged. Always read the `_fs` cookie and call resolve from your backend.
</Warning>

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

```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
}
```

<Note>
  Prefer the after-auth server call as your **primary** bind — it has the `_fs` cookie, so correlation is strongest. Use the webhook to catch accounts created outside your UI; on its own it can only do an **IP join**. To get cookie-strength correlation from a webhook, persist the `_fs` value at signup (keyed by email or Clerk session id) and pass it as `session_id`.
</Note>

## 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
```

<CardGroup cols={2}>
  <Card title="Blended verdict" icon="shield">
    `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.
  </Card>
  <Card title="Multi-account correlation" icon="users">
    `accounts_sharing_fingerprint`, `accounts_sharing_ip`, `accounts_sharing_session`, and `linked_emails` — the core fake- and duplicate-signup tell. Invisible per-request, obvious here.
  </Card>
  <Card title="Session continuity" icon="clock">
    `is_fresh` plus first/last seen and age. A history-less session arriving straight at signup is weak but useful corroborating evidence of automation.
  </Card>
  <Card title="Durable binding" icon="fingerprint">
    `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.
  </Card>
</CardGroup>

## 5. Security and reliability notes

<Note>
  **Server-only secret key.** The verdict only ever returns to a secret-key (`sessions` scope) backend call. Never call resolve from the browser, never relay a verdict through client code, and always read the `_fs` cookie server-side from the incoming request.
</Note>

<Note>
  **Best-effort, non-blocking.** Treat resolve as advisory. Wrap it in a try/catch (and ideally a short timeout), and let signup and login proceed if FormShield is slow or unreachable — never block a real user from authenticating on a reputation lookup.
</Note>

## Next steps

<CardGroup cols={2}>
  <Card title="Next.js + Clerk" icon="react" href="/guides/nextjs-clerk">
    Copy-pasteable Next.js 15/16 App Router code: `next/script`, `cookies()`, Route Handlers, and the webhook route.
  </Card>
  <Card title="Auth Protection" icon="user-shield" href="/products/auth-protection">
    The product behind this guide — the full resolve contract, fields, credits, and the `/v1/auth/check` pre-flight.
  </Card>
  <Card title="IP Intelligence" icon="globe" href="/products/ip-intelligence">
    The IP reputation lookup feeding the verdict — datacenter, VPN, proxy, Tor, and residential-proxy tags.
  </Card>
  <Card title="Email Intelligence" icon="envelope" href="/products/email-intelligence">
    Disposable-domain, deliverability, and domain-age checks that feed the email signal.
  </Card>
</CardGroup>
