Signup guard: block disposable emails and repeat signups
Catch disposable emails, undeliverable domains and one person signing up again and again for your free tier. The guard works with only a secret key: no plans, no events and no identify call are needed. It needs @vevee/sdk 1.15.0 or later.
How it works
- Two calls on the signup step. The browser reads a device blob with
collectDevice(); your server sends it with the email toguard.signup(). Vevee answers with arecommendationand thereasonsbehind it. - You decide. The guard returns advice. Your code refuses the signup, gives no free quota, flags it for review or lets it through.
- Hashes only. The email, the network prefix of the IP and the device are stored as keyed hashes, one secret per app. An app only ever matches its own signups.
- Secret key only. A public key in a browser would let anyone burn your guard allowance. The SDK refuses a
pk_key locally: it makes no request, resolves to an unavailable verdict (risk: "unknown",unavailableReason: "guard_unavailable", recommendation fromguardUnavailable, defaultallow) and reports the error toonGuardError. A raw HTTP call to the route with apk_key gets HTTP 403 with the error coderequires_secret_key. Shipping a public key therefore means silent allows.
1. Install
npm install @vevee/sdk@^1.15.0// lib/vevee.ts (server only)
import { createClient } from '@vevee/sdk';
export const vevee = createClient({
apiKey: process.env.VEVEE_SECRET_KEY!,
guardUnavailable: 'allow', // or 'review', see "When Vevee is unreachable"
});2. Collect the device on the signup step
Call collectDevice() from @vevee/sdk/browser only on the signup form, then send the result to your own signup route with the email.
// signup-form.tsx (client)
'use client';
import { collectDevice } from '@vevee/sdk/browser';
async function onSubmit(email: string) {
const device = await collectDevice(); // never throws
await fetch('/api/signup', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ email, device }),
});
}collectDevice() reads browser signals (listed under Privacy and consent) and stores one random id in localStorage under the key vv_g. It holds no Vevee key and makes no request. See Privacy and consent before shipping it.
3. Check on the server
Use guardContextFromHeaders to pass the IP, the TLS fingerprint and the user agent. The IP travels in the request body only; Vevee uses it in memory to derive a hashed network prefix (/24 for IPv4, /48 for IPv6) and never stores or logs it.
// app/api/signup/route.ts
import { guardContextFromHeaders } from '@vevee/sdk';
import { vevee } from '@/lib/vevee';
export async function POST(req: Request) {
const { email, device } = await req.json();
const userId = crypto.randomUUID();
const verdict = await vevee.guard.signup({
userId,
email,
device,
context: guardContextFromHeaders(req.headers),
});
if (verdict.recommendation === 'block') {
return Response.json({ error: 'Use a different email.' }, { status: 400 });
}
const freeQuota = verdict.recommendation === 'deny_free' ? 0 : 10;
// create the account with freeQuota; queue a review if 'review'
return Response.json({ ok: true, freeQuota });
}guardContextFromHeaders takes the IP from the first header present, in this order: x-vercel-forwarded-for, cf-connecting-ip, x-real-ip, and only then the left-most x-forwarded-for entry. That last one can be spoofed by a client behind Cloudflare or nginx. Behind any other proxy, pass ip yourself:
context: { ...guardContextFromHeaders(req.headers), ip: trustedClientIp }Check an email before sending the OTP
guard.checkEmail()runs the email rules only (no device match) and stores nothing. Use it before you send the signup code (OTP), or for a “did you mean gmail.com?” hint.
const check = await vevee.guard.checkEmail(email);
if (check.recommendation === 'block') return refuse();Read the recommendation and reasons
| recommendation | Meaning |
|---|---|
allow | Nothing to act on. |
review | Allow the signup, flag it for a human look. |
deny_free | Create the account but give no free quota. |
block | Refuse the signup. |
risk is low, medium, high or unknown. reasons lists the rules that fired, so you can show the right message: invalid_email, disposable_email, undeliverable_domain, relay_email, new_domain, blocked_domain, suspect_mail_server, email_seen, device_high, device_medium and checks_unavailable. When several rules fire, the strictest action wins. Matched accounts come back as a count, never as ids or emails.
The action for each rule is set per app on the dashboard's guard settings. Every change is written to a history so a shift in signup numbers can be traced to a setting.
deny_free. Two people with the same phone model share the canvas, fonts and TLS fingerprint. Two classmates on the same school Wi-Fi score about 53, or 68 within 30 minutes of each other: both medium, and close to what one person making a second account in incognito scores. Keep medium on review and let only device_high (same browser storage or same normalized email) take away the free plan.Report what happened next
Tell Vevee whether the signup turned out real. This feeds a per-domain feedback loop that helps spot new disposable domains. It does not count against your guard allowance.
await vevee.guard.outcome({ userId, email, outcome: 'verified' });
// 'verified' | 'never_verified' | 'abuse'Start in observe mode, then enforce
A new guard starts in observe: the recommendation is always allow, but the action it would have taken is recorded and shown on the guard page. Run a week, look at the “what would enforce have done” count and the reasons, correct false positives with the allowlist, then switch the mode to enforce. Shared offices, universities and family devices are the usual false positives.
Allowance and degraded checks
The guard has its own allowance: 20% of your plan's ops per period (10,000 a month on Free). Every checkEmail and signup call counts one. It is separate from metering. Guard calls never add to the usage that blocks track or reserve, so the guard can never cause workspace_limit_reached, and metering never limits the guard. Cached domain lookups still count. outcome calls and degraded answers do not.
When the allowance is used up, Vevee does not simply allow everything. It runs a degraded check: the allow and block lists, the disposable lists, burner mail servers already flagged, the relay list and repeated emails are still checked, while live DNS and RDAP domain age lookups and device matching are skipped. The answer carries degraded: true and unavailableReason: "guard_cap_reached". The dashboard setting “When checks are unavailable” (allow or review) applies to every degraded answer: when none of the checks that still run fires, it decides the action, and review adds the reason checks_unavailable. Past a rate limit on degraded answers, Vevee runs no checks at all and answers with that setting. The same setting applies when Vevee answers but cannot run its checks (unavailableReason: "guard_unavailable"). You get one email alert per period and a banner on the guard page.
When Vevee is unreachable
The guard methods never throw. On a network failure, a 3 second timeout, a 4xx or a 5xx they return risk: "unknown" and unavailableReason: "guard_unavailable". The SDK cannot read your dashboard settings when it cannot reach Vevee, so guardUnavailable on createClient decides the recommendation: 'allow' (default) or 'review', which adds the reason checks_unavailable. Mirror the dashboard setting here. Log failures with the optional callback:
createClient({
apiKey: process.env.VEVEE_SECRET_KEY!,
guardUnavailable: 'review',
onGuardError: (err) =>
console.warn('guard unavailable', err instanceof Error ? err.message : err),
});Going live
The onboarding key is an sk_test_ key, and checks with it run against test data. Test and live signups never match each other, so a repeat signup you test with a test key will not be caught in production.
- Open your app's Keys page at
/apps/<id>/keysand create a live secret key (sk_live_...). - Put it in your production environment as
VEVEE_SECRET_KEY. - Keep the
sk_test_key for development and staging.
outcome calls made with a test key are ignored.
Erasure and export
deletePersondeletes the person's guard rows, including rows stored under any alias id. If you want a deleted account to stay recognisable, pass keepGuard:
await vevee.analytics.deletePerson(userId, { keepGuard: true });Each row then loses its end user id (replaced by a random value linked to nothing) and its hashes are kept until the row's original 180 day expiry, never extended. A new account has no link to the kept row; it only adds to match scores. keepMetering: true implies keepGuard. exportPerson includes the guard rows, as hashes only.
Privacy and consent
- Purpose. Abuse prevention at signup only. Guard data is never read by analytics, funnels, attribution, profiles, exports, the Insights API or MCP.
- What is stored. Keyed hashes (HMAC-SHA256, a per-app secret) of the normalised email, the IP network prefix and the device. No raw IP, user agent or fingerprint value. No cross-app matching. Retention is 180 days.
- Email domain. Stored only when it is public: disposable email services, known throwaway mail servers, relays and large consumer providers such as
gmail.com. Private and company domains are never stored; the dashboard shows them as “Private domain”. The domain is erased with the person, is not kept on rows retained withkeepGuard, and is included in the export. - Dashboard.No emails, no private domains and no matched account ids are shown, and there is no export of the flagged or blocked lists. “Check a domain” stores nothing about the check.
- Device signals. In the browser: a canvas rendering hash, the WebGL vendor and renderer, an audio context hash, an installed-fonts mask, screen and hardware characteristics, the timezone, the language and the random
localStorageidvv_g. On the server Vevee adds the JA4 TLS digest and a reduced user agent class. All of these are stored only as keyed hashes. - Third parties. Email domain lookups use DNS over HTTPS (Cloudflare) and RDAP (rdap.org). They receive the domain only, never the address.
- The device identifier needs a decision.
collectDevice()writes a random id tolocalStorage(vv_g). Under ePrivacy Art. 5(3) and EDPB Guidelines 2/2023 that is access to the user's device, so it needs consent unless it is strictly necessary for the service the user asked for. Treating abuse screening as a security measure is defensible but not settled. If you do not want to rely on it, ask for consent before callingcollectDevice(), or skip the device step: the email checks work alone.
These are not legal advice. Start from the LIA template and the privacy-policy paragraph, and see the DPA and sub-processors.
Where to next
- Privacy & GDPR: erasure and export for the rest of your data.
- Managing subscriptions: add a free plan and quota when you want Vevee to enforce it.