Docs menuAll pages, quickstarts and this page’s contents
- Androidsoon
- iOSsoon
- React Nativesoon
- Fluttersoon
Nuxt quickstart
Nuxt gives you the browser and the server in one project, so a fraud integration is a composable on one side and a server route on the other. This is Nuxt 4, end to end.
Estimated time: under 15 minutes
Before you start
- Node 22+. Nuxt 4 requires it.
- Nuxt 4. Client code lives in
app/and server code inserver/; on Nuxt 3 drop theapp/prefix. - A free Kaidn account. Register: 10,000 events a month, no card.
You finish this one protected. By the last step a real signup is scored and a block verdict actually stops it.
Get your two keys
- A publishable key (
pk_live_…) is read by the browser. It is meant to be public. - A secret key (
sk_live_…) is what scores events. It must never reach the browser.
Both are in your dashboard. The secret key is shown once.
Nuxt is private by default, and that is the opposite of Next. In Next any variable named NEXT_PUBLIC_* is inlined into the browser bundle, so the dangerous thing is one prefix away. In Nuxt, everything in runtimeConfig stays on the server unless you nest it under public, so exposure is something you opt into on purpose. If you came from the Next guide, unlearn the prefix habit.
Set up your project
npm create nuxt@latest kaidn-demo cd kaidn-demo npm install @kaidn/nuxt
One package covers both halves. @kaidn/nuxt gives you the composable for the browser, and @kaidn/nuxt/server gives you the scorer. Nothing imports both.
Configure the keys
Declare both in runtimeConfig. The empty strings are deliberate: they are placeholders that the environment fills at runtime, and they document which keys exist.
export default defineNuxtConfig({ runtimeConfig: { // Server only. Never move this line inside `public`. kaidnApiKey: "", public: { // Sent to the browser on purpose. kaidnPk: "", }, }, });
NUXT_KAIDN_API_KEY=sk_live_your_secret_key NUXT_PUBLIC_KAIDN_PK=pk_live_your_publishable_key
The env names are not free-form. Nuxt maps NUXT_KAIDN_API_KEY to runtimeConfig.kaidnApiKey and NUXT_PUBLIC_KAIDN_PK to runtimeConfig.public.kaidnPk, by uppercasing and splitting on the nesting. Reading process.env.SOMETHING_ELSE as a default in the config works at build time and then breaks at runtime, which is a bad way to find out.
Collect on submit
Wrap the package’s hook in your own composable so the key is read in one place. It collects when you ask it to rather than on mount, which puts the work at the moment somebody acts and keeps fingerprinting off pages that do not need it.
import { useDeviceId } from "@kaidn/nuxt"; export function useKaidn() { return useDeviceId(useRuntimeConfig().public.kaidnPk); }
The key is passed in, not discovered. The package never imports useRuntimeConfigitself. Reaching into Nuxt’s virtual imports would save you this one line and tie the package to an internal that moves between versions, so it takes an argument instead and works the same on Nuxt 3, Nuxt 4 and plain Vue.
<script setup lang="ts"> const { getDeviceId } = useKaidn(); const email = ref(""); const message = ref(""); async function onSubmit() { const deviceId = await getDeviceId(); const res = await $fetch("/api/signup", { method: "POST", body: { email: email.value, deviceId }, }); message.value = res.message; } </script> <template> <form @submit.prevent="onSubmit"> <input v-model="email" type="email" required /> <button type="submit">Create account</button> <p>{{ message }}</p> </form> </template>
Score it in a server route
This is the half that actually stops anything. Note the import path: @kaidn/nuxt/server, not @kaidn/nuxt.
import { createKaidn } from "@kaidn/nuxt/server"; export default defineEventHandler(async (event) => { const { kaidnApiKey } = useRuntimeConfig(event); const kaidn = createKaidn({ apiKey: kaidnApiKey }); const { email, deviceId } = await readBody(event); const result = await kaidn.score(event, { event: "signup", email, device_id: deviceId ?? undefined, }); if (result.verdict === "block") { throw createError({ statusCode: 403, statusMessage: "Signup refused" }); } if (result.verdict === "review") { // Let them in, flag it for a human. Most fraud lives here, not in "block". await flagForReview(email, result.reasons); } await createUser(email); return { ok: true, message: "Account created." }; });
A server route is a public endpoint. Anyone can POST /api/signup directly, without ever loading your form. So the verdict has to be enforced here, inside the handler, as above. Hiding the form or branching in the component is not a check.
useRuntimeConfig(event) takes the event on the server. Passing it is what lets Nuxt resolve per-request config correctly, so do not reach for process.env here.
Test it
npm run dev
Submit the form, then open your events. A scored signup appears with its verdict and the reasons behind it. Submit twice from the same browser and the second one carries device_reuse.
{
"score": 15,
"verdict": "allow",
"reasons": ["device_reuse"],
"reason_text": "This device is linked to 2 accounts."
}reasons holds machine-readable codes to branch on; reason_text is the sentence written for a human. Log the second one, switch on the first.
What the package does for you
Two fields never appeared in that handler, and both matter. They come out of the h3 event rather than out of your code.
The IP
score() calls h3’s getRequestIP(event), which returns an address the server already resolved. Written by hand this is the field people forget, and forgetting it is quiet: the event goes out with no ip, every IP signal (datacenter, proxy, ASN) stops firing, and the integration still looks like it works.
There is deliberately no option to trust x-forwarded-for. h3 can be told to read it, and its own docs say to make sure the header can be trusted first, because the first entry is client-supplied: unless something you control overwrites it on every request, a caller can put any address they like there and walk through IP allow-lists, rate limits and geo checks. A fraud library offering that as a switch is offering a footgun, so it does not. If you genuinely need it, pass ip on the event yourself and it wins.
If you read our Next.js guide you saw the IP read out of x-forwarded-for by hand. That is not an inconsistency: a Next Server Action has no request object at all, so the header is the only thing available there. Here the server resolved it for you.
The device token
A raw fingerprint collides: two different people on the same phone model and browser can hash to the same id. The package reads the first-party cookie on the way in and writes the new token back on the way out, which turns a guess into a browser you actually recognise. What that buys you is described in device identity.
Need something the wrapper does not cover, like /v1/check/email or a label? kaidn.client is the underlying @kaidn/sdk instance, with every endpoint on it.
Next steps
- Core concepts — how a verdict is made, and where the engine fails. Six ideas.
- Guides — guarding a cashout, catching account takeover, stopping multi-accounting.
- API reference — every field on the event and every reason code that can come back.