NestJS gives you three hooks that map neatly onto a signup-abuse check. A provider owns the Prynt client. A guard decides whether the request may proceed. An interceptor runs after the handler and can act on what it returned. Put the device check in those three places and your registration controller stays exactly as it was.
This post builds each piece with @prynt/node. The flow is the standard one: the browser identifies and sends a requestId, the server fetches the event with the secret key, and after the account exists the server attaches its id so the next signup from that device sees it. The Node server-side verification guide covers the SDK on its own if you want the basics first.
The provider
// prynt/prynt.module.ts
import { Global, Module } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { PryntServer } from '@prynt/node';
export const PRYNT = Symbol('PRYNT');
@Global()
@Module({
providers: [{
provide: PRYNT,
inject: [ConfigService],
useFactory: (config: ConfigService) =>
new PryntServer({ secretKey: config.getOrThrow('PRYNT_SECRET_KEY'), timeoutMs: 3000 }),
}],
exports: [PRYNT],
})
export class PryntModule {}
PryntServer refuses to construct without a key starting with sk_, so a misconfigured environment fails at boot rather than on the first signup. The default timeout is 5000 ms; 3000 is a reasonable ceiling for a signup form.
The guard
The guard returns true to proceed or throws to refuse. It also attaches a verdict to the request so later code can read it.
// prynt/signup-abuse.guard.ts
import { CanActivate, ExecutionContext, ForbiddenException, Inject, Injectable, Logger } from '@nestjs/common';
import { PryntError, PryntServer } from '@prynt/node';
import { PRYNT } from './prynt.module';
export type SignupAction = 'allow' | 'flag' | 'block';
export interface SignupVerdict {
action: SignupAction;
reasons: string[]; // our policy reasons
pryntReasons: string[]; // raw reasons from Prynt's risk block
requestId?: string;
visitorId?: string;
accountsOnDevice?: number;
}
const MAX_ACCOUNTS_PER_DEVICE = 2;
@Injectable()
export class SignupAbuseGuard implements CanActivate {
private readonly log = new Logger(SignupAbuseGuard.name);
constructor(@Inject(PRYNT) private readonly prynt: PryntServer) {}
async canActivate(ctx: ExecutionContext): Promise<boolean> {
const req = ctx.switchToHttp().getRequest();
const requestId = req.body?.pryntRequestId;
const verdict: SignupVerdict = { action: 'allow', reasons: [], pryntReasons: [] };
req.pryntVerdict = verdict;
if (typeof requestId !== 'string' || !requestId) {
verdict.action = 'flag';
verdict.reasons.push('missing_request_id');
return true;
}
try {
const event = await this.prynt.getEvent(requestId);
verdict.requestId = requestId;
verdict.visitorId = event.visitorId;
verdict.accountsOnDevice = event.accountsOnDevice.count;
verdict.pryntReasons = (event.risk?.reasons as string[] | undefined) ?? [];
if (event.linkedId) verdict.reasons.push('request_id_reused');
if (event.decision === 'block') verdict.reasons.push('prynt_block');
if (event.accountsOnDevice.count >= MAX_ACCOUNTS_PER_DEVICE) verdict.reasons.push('device_account_limit');
if (event.decision === 'challenge') verdict.reasons.push('prynt_challenge');
} catch (err) {
if (err instanceof PryntError && err.status === 404) {
verdict.reasons.push('event_not_found');
} else {
this.log.warn(`Prynt unavailable: ${(err as PryntError).code ?? err}`);
verdict.action = 'flag';
verdict.reasons.push('prynt_unavailable');
return true; // fail open
}
}
const blocking = ['request_id_reused', 'prynt_block', 'device_account_limit', 'event_not_found'];
if (verdict.reasons.some((r) => blocking.includes(r))) {
verdict.action = 'block';
this.log.log(`signup refused ${verdict.visitorId ?? '-'}: ${verdict.reasons.join(',')}`);
throw new ForbiddenException({
error: 'signup_refused',
message: "We couldn't create an account from this device. If this is a mistake, contact support.",
reasons: verdict.reasons,
});
}
if (verdict.reasons.length) verdict.action = 'flag';
return true;
}
}
Why the reasons are structured
The response body carries reasons, an array of stable strings, rather than a sentence. Your frontend can branch on them (show a support link for device_account_limit, retry identification for event_not_found), and your logs can be grouped by them. It’s the same argument as explainable reason codes: a bare 403 tells nobody anything.
Keep the user-facing message vague. The reasons array is fine to return because it describes your policy, not your detection internals, but don’t echo Prynt’s raw risk reasons to the client. Those belong in your logs.
Two checks people skip
event.linkedIdalready set means this requestId was attached to an account before. Request ids don’t expire, so a reused one is a replay and should be refused.- 404 from
getEventmeans a requestId your secret key never issued: forged, or from another environment. A key only sees its own environment (live, test or staging), so a test-mode id against a live key is also a 404.
The interceptor
After the handler creates the user, link it to the device:
// prynt/link-account.interceptor.ts
import { CallHandler, ExecutionContext, Inject, Injectable, Logger, NestInterceptor } from '@nestjs/common';
import { mergeMap } from 'rxjs';
import { PryntServer } from '@prynt/node';
import { PRYNT } from './prynt.module';
@Injectable()
export class LinkAccountInterceptor implements NestInterceptor {
private readonly log = new Logger(LinkAccountInterceptor.name);
constructor(@Inject(PRYNT) private readonly prynt: PryntServer) {}
intercept(ctx: ExecutionContext, next: CallHandler) {
const req = ctx.switchToHttp().getRequest();
return next.handle().pipe(
mergeMap(async (user) => {
const requestId = req.pryntVerdict?.requestId;
if (requestId && user?.id) {
try {
await this.prynt.updateEvent(requestId, { linkedId: String(user.id) });
} catch (err) {
this.log.warn(`link failed for ${user.id}: ${err}`);
}
}
return user;
}),
);
}
}
updateEvent sends PUT /v1/events/{requestId} with { linkedId }. Without it, accountsOnDevice never grows and the guard’s limit never trips, so treat a failure here as something to alert on, not just log. Awaiting it inline closes the window where two parallel signups from one device both pass; if you’d rather keep it off the request path, push it to a queue with retries.
Wiring it to the route
@Controller('auth')
export class AuthController {
constructor(private readonly users: UsersService) {}
@Post('signup')
@UseGuards(SignupAbuseGuard)
@UseInterceptors(LinkAccountInterceptor)
async signup(@Body() dto: SignupDto, @Req() req) {
return this.users.create(dto, { review: req.pryntVerdict.action === 'flag' ? req.pryntVerdict.reasons : null });
}
}
If you use a global ValidationPipe with whitelist: true, add pryntRequestId to SignupDto as an optional string, or it’ll be stripped from the body before it matters for anything downstream. The guard itself reads the raw body, since guards run before pipes.
Choosing the limit
MAX_ACCOUNTS_PER_DEVICE = 2 refuses the third account from a device. For a product where every account gets paid compute, 1 is defensible. For a product used on shared machines, keep it at 2 or higher and lean on the flag path: let the account exist, but gate credits behind a phone or card step. Device-based signup limits walks through how to pick, and the event fields are documented in the docs.
Start with the guard in flag-only mode for a week (replace the throw with a log line), read the reasons it would have refused, and then turn on enforcement once the numbers look like abuse rather than households.
Try it free
Prynt is device intelligence with a free tier — visitor IDs, bot & fraud Smart Signals, and behavioral biometrics, powered by a cross-site network. Start free.