All articles Integration

Device Limits in a Lucia-Style Custom Auth Setup

The Lucia approach to authentication is a deliberate choice to own the code: you hash the password, insert the user, generate a session token, store its hash, and set the cookie yourself. No hosted provider, no hooks, no dashboard rules. That ownership is the point, and it also means nobody else will stop one person from creating forty accounts.

The upside is that you control exactly where a device check goes. This post walks through a hand-rolled signup handler and shows where the three Prynt steps fit, and the ordering mistakes that quietly break them.

The handler you probably already have

Stripped down, a custom-auth signup handler looks like this:

async function signup(req: Request) {
  const { email, password } = await parse(req);
  validate(email, password);
  const passwordHash = await hash(password);
  const user = await db.insertUser({ email, passwordHash });
  const token = generateSessionToken();
  await db.insertSession({ id: sha256(token), userId: user.id, expiresAt: in30Days() });
  return redirectWithCookie('/app', token);
}

There are three places a device check could go: before validation, between validation and the insert, or after the session exists. Only one of them is right for each step.

Step 1: identify in the browser

The signup page loads the agent and identifies on submit, sending the requestId with the form.

import Prynt from '@prynt/js';

const prynt = await Prynt.load({ apiKey: 'pk_live_…' });

form.addEventListener('submit', async (e) => {
  e.preventDefault();
  try {
    const { requestId } = await prynt.identify({ tag: { action: 'signup' } });
    form.elements.requestId.value = requestId; // a hidden <input name="requestId">
  } catch {
    // blocked or offline: submit without it, the server flags the signup
  }
  form.submit();
});

Only the requestId travels. The visitorId in the browser result is convenient for display, but the server must read it from the API, never from a form field.

Step 2: look up and decide before any write

Put the event lookup after cheap validation and before password hashing. Validation failures should not cost an API call, and hashing is deliberately slow, so there is no reason to spend it on a signup you are about to refuse.

import { PryntServer, PryntError } from '@prynt/node';

const prynt = new PryntServer({ secretKey: process.env.PRYNT_SECRET_KEY! });
const MAX_ACCOUNTS_PER_DEVICE = 1; // one trial per device

async function checkDevice(requestId: string | undefined) {
  if (!requestId) return { action: 'flag' as const, event: null };
  try {
    const event = await prynt.getEvent(requestId);
    if (event.decision === 'block') return { action: 'block' as const, event };
    if (event.linkedId) return { action: 'block' as const, event }; // requestId reused
    if (event.accountsOnDevice.count >= MAX_ACCOUNTS_PER_DEVICE) {
      return { action: 'block' as const, event };
    }
    if (event.decision === 'challenge') return { action: 'flag' as const, event };
    return { action: 'allow' as const, event };
  } catch (err) {
    if (err instanceof PryntError && err.status === 404) {
      return { action: 'flag' as const, event: null }; // unknown requestId
    }
    return { action: 'allow' as const, event: null };    // outage: fail open
  }
}

The four outcomes mirror the policy module the official recipes share: allow, flag, verify, block. flag creates the account but marks it for review; verify creates it but holds back the trial until an extra step. Your handler decides what each means.

The key property here is that nothing has been written yet. If you refuse, there is no orphan user row, no session, and nothing to clean up.

Step 3: create user and session in one transaction

Lucia-style code often inserts the user and the session separately. Wrap them together so a failure in the session insert does not leave a user without one.

async function signup(req: Request) {
  const { email, password, requestId } = await parse(req);
  validate(email, password);

  const verdict = await checkDevice(requestId);
  if (verdict.action === 'block') {
    return json(403, { error: "We couldn't create an account from this device." });
  }

  const passwordHash = await hash(password);
  const token = generateSessionToken();

  const user = await db.transaction(async (tx) => {
    const u = await tx.insertUser({
      email, passwordHash,
      reviewFlag: verdict.action === 'flag',
      signupRequestId: requestId ?? null,
    });
    await tx.insertSession({ id: sha256(token), userId: u.id, expiresAt: in30Days() });
    return u;
  });

  if (verdict.event) {
    await linkDevice(requestId!, String(user.id));
  }
  return redirectWithCookie('/app', token);
}

Storing signupRequestId on the user row costs one column and pays for itself twice: it lets you retry the link later, and it gives support an audit trail when someone disputes a refusal.

The PUT /v1/events/{requestId} call is what makes the next signup from this device see this account. It belongs after the transaction commits, for two reasons.

Before the insert, you have no id. You could pre-generate one, but then a rolled-back insert leaves the device carrying a phantom account. A legitimate retry, say after a unique-email violation, would then be refused for an account that does not exist.

Inside the transaction, you hold locks during a network call. A slow response keeps the transaction open, which on a busy signup table becomes contention. Network calls and database transactions do not mix.

async function linkDevice(requestId: string, userId: string) {
  try {
    await prynt.updateEvent(requestId, { linkedId: userId });
  } catch {
    await jobs.enqueue('prynt-link', { requestId, userId }); // retry later
  }
}

If the link fails and you never retry, the cost is that the device appears to hold one fewer account than it does. That is a missed detection, not a false positive, which is the right direction to fail.

The race you can ignore, and the ordering bug you cannot

Two signups from the same device, submitted in the same second, can both read count: 0 and both pass. For a trial-abuse control that is acceptable: it is a narrow window, the third attempt will see two accounts, and closing it requires a lock keyed on visitorId that is not worth the complexity for most products.

The bug you cannot ignore is a stored session created for a refused signup. If your handler sets the cookie before checking the device, a refused user still walks away logged in. Keep the order strict: decide, write, link, then set the cookie.

Sign-in is a different question

Do not reuse the account limit on login. A device that holds two accounts is allowed to sign in to both; that is how shared laptops work. At login you care about other signals, a new device on an established account, automation, or a known-bad reputation, which is the account takeover problem rather than multi-accounting.

Where to go next

The Node server-side verification post covers the SDK’s error types and timeouts in more depth. Device-based signup limits discusses how to pick the number, and one account per person covers the products where the limit really is one. The event fields used here are documented in the docs.

Owning your auth code means owning these four steps too. Put the lookup before the write, the link after the commit, and store the requestId so you can always finish what a failed request started.

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.

Keep reading