All articles Integration

Cognito Pre Sign-up Lambda: Device Limits for Trial Abuse

Amazon Cognito gives you a hosted user pool and a set of Lambda triggers around the sign-up lifecycle. The Pre sign-up trigger runs before the user is created and can refuse the attempt, which makes it the natural place to ask a question Cognito cannot answer by itself: how many accounts has this device already opened?

This guide wires a per-device account limit into Cognito with two triggers and the Prynt REST API. There is no SDK dependency in the Lambda; Node.js 20’s built-in fetch is enough.

The flow

  1. The sign-up page identifies the device and gets a requestId.
  2. The client calls SignUp with the requestId in ClientMetadata.
  3. The Pre sign-up Lambda calls GET /v1/events/{requestId} and throws if the device is over the limit or Prynt’s decision is block.
  4. The Post confirmation Lambda calls PUT /v1/events/{requestId} with { "linkedId": sub }, so the next sign-up from that device counts this account.

Linking happens after confirmation because the Pre sign-up trigger runs before Cognito has a confirmed user to link.

Step 1: identify and send clientMetadata

With Amplify v6, signUp accepts clientMetadata in its options:

import { signUp, confirmSignUp } from 'aws-amplify/auth';

// <script src="https://api.pryntid.com/cdn/prynt.umd.js"></script> in the page
const ready = Prynt.load({ apiKey: 'pk_live_…' })
  .then((agent) => agent.identify({ tag: { action: 'signup' } }))
  .catch(() => null);

async function register(email, password) {
  const requestId = (await ready)?.requestId || '';
  sessionStorage.setItem('prynt_rid', requestId);
  return signUp({
    username: email,
    password,
    options: {
      userAttributes: { email },
      clientMetadata: { pryntRequestId: requestId },
    },
  });
}

async function confirm(email, code) {
  return confirmSignUp({
    username: email,
    confirmationCode: code,
    options: { clientMetadata: { pryntRequestId: sessionStorage.getItem('prynt_rid') || '' } },
  });
}

ClientMetadata is passed to the trigger but not stored on the user, so it never leaks into tokens or attributes. The values are strings, which is all you need. The confirmation call sends it again for the Post confirmation trigger.

Step 2: handle the secret

The Lambda needs your secret key (sk_…). Put it in Secrets Manager and read it once per container, outside the handler, so warm invocations pay nothing:

// shared/prynt.mjs
import { SecretsManagerClient, GetSecretValueCommand } from '@aws-sdk/client-secrets-manager';

const sm = new SecretsManagerClient({});
let secretPromise;

export function pryntSecret() {
  secretPromise ??= sm
    .send(new GetSecretValueCommand({ SecretId: process.env.PRYNT_SECRET_ARN }))
    .then((r) => r.SecretString);
  return secretPromise;
}

export async function prynt(method, path, body) {
  const res = await fetch(`https://api.pryntid.com${path}`, {
    method,
    headers: {
      Authorization: `Bearer ${await pryntSecret()}`,
      'Content-Type': 'application/json',
    },
    body: body ? JSON.stringify(body) : undefined,
    signal: AbortSignal.timeout(2000),
  });
  return { status: res.status, data: res.ok ? await res.json() : null };
}

Grant the function role secretsmanager:GetSecretValue on that one ARN. The AWS SDK v3 ships in the Node.js Lambda runtime, so there is nothing to bundle. Keep the Prynt timeout short: Cognito expects triggers to answer within a few seconds, and a slow trigger turns into a failed sign-up for a real user. Remember too that a secret key only sees its own environment, so use a test key on your staging pool.

Step 3: the Pre sign-up trigger

// pre-signup.mjs
import { prynt } from './shared/prynt.mjs';

const MAX_ACCOUNTS = Number(process.env.PRYNT_MAX_ACCOUNTS_PER_DEVICE || 2);
const MAX_AGE_MS = 15 * 60 * 1000;

export const handler = async (event) => {
  if (event.triggerSource !== 'PreSignUp_SignUp') return event; // admin/social: see below

  const rid = event.request.clientMetadata?.pryntRequestId;
  if (!rid) {
    console.log('prynt: missing requestId', event.userName); // flag, don't block
    return event;
  }

  let res;
  try {
    res = await prynt('GET', `/v1/events/${encodeURIComponent(rid)}`);
  } catch (err) {
    console.warn('prynt unreachable, failing open', err.name);
    return event;
  }
  if (res.status === 404) throw new Error('Please reload the page and try again.');
  if (!res.data) return event; // 5xx or bad key: fail open

  const ev = res.data;
  const stale = Date.now() - Date.parse(ev.createdAt) > MAX_AGE_MS;
  const replayed = Boolean(ev.linkedId);
  const overLimit = ev.accountsOnDevice.count >= MAX_ACCOUNTS;

  if (stale || replayed) throw new Error('Please reload the page and try again.');
  if (ev.decision === 'block' || overLimit) {
    console.log('prynt: denied', ev.visitorId, ev.accountsOnDevice.count, ev.risk?.reasons);
    throw new Error('We could not create an account from this device.');
  }
  return event;
};

Throwing aborts the sign-up. The client receives a UserLambdaValidationException whose message includes your text, so keep it generic and never mention the count or the rule. Detail belongs in your logs, not in a hint for the person tuning a script.

The stale and replayed checks matter more in Cognito than elsewhere, because SignUp is a public API. Anyone can call it directly with any ClientMetadata. A requestId must be recent and not already attached to an account, otherwise one clean identification could be reused for a thousand sign-ups.

Step 4: the Post confirmation trigger

// post-confirmation.mjs
import { prynt } from './shared/prynt.mjs';

export const handler = async (event) => {
  if (event.triggerSource !== 'PostConfirmation_ConfirmSignUp') return event;
  const rid = event.request.clientMetadata?.pryntRequestId;
  const sub = event.request.userAttributes.sub;
  if (rid) {
    try {
      await prynt('PUT', `/v1/events/${encodeURIComponent(rid)}`, { linkedId: sub });
    } catch (err) {
      console.warn('prynt link failed', sub, err.name); // retry from a queue if you need certainty
    }
  }
  return event;
};

The triggerSource check keeps password-reset confirmations from re-linking. Using the sub as the linkedId gives you a stable id that matches what your backend sees in tokens, and the user’s accountsOnDevice entry will show firstSeenAt and lastSeenAt for it.

If the user confirms from a different browser, by opening the email on their phone for instance, sessionStorage is empty and the link is skipped. If that matters to you, write the requestId and userName to a small DynamoDB table in the Pre sign-up trigger and read it back here instead.

Admin-created and federated users

Users created through AdminCreateUser arrive with PreSignUp_AdminCreateUser and are your own staff’s doing, so skip them. Users from Google or other identity providers arrive with PreSignUp_ExternalProvider and no client metadata. Social login verifies an email, not a person, so those sign-ups deserve the same limit; the usual pattern is to identify and check on the first authenticated request in your app instead of inside Cognito.

Rolling it out

Deploy the Pre sign-up trigger with the throw lines commented out and log the decisions for a week. You will see how many sign-ups already share a device, which tells you whether a limit of 1 or 2 is right for your audience. The broader policy questions are covered in device-based sign-up limits and protecting sign-up from fraud; if part of your stack is Node outside Lambda, server-side verification in Node shows the same check with @prynt/node. Field details for the events endpoint are in the docs.

Once the numbers look right, enable the throw and keep watching the log line for denied devices. It is the cheapest fraud report you will ever build.

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