Auth.js

Connect Auth.js (NextAuth.js) signups to referral clicks with one copied file, with no storage in your database.

The RefRef recipe for Auth.js connects each new account to the browser's referral click. It is one file that you copy into your application. It adds nothing to your database.

Set up

  1. Add the landing script to the pages where a referral link can land. It keeps the browser's referral handle in the refref_handle cookie.
  2. Register your application as a source with POST /v1/sources and keep its sourceNamespaceId.
  3. Copy the recipe file into your application as refref.ts, and connect it in your Auth.js configuration:
import NextAuth from "next-auth";
import { createRefRef } from "./refref";
import { adapter } from "./db";

const refref = createRefRef({
  apiUrl: "https://api.refref.ai",
  apiKey: process.env.REFREF_API_KEY!,
  projectId: process.env.REFREF_PROJECT_ID!,
  sourceNamespaceId: async () => process.env.REFREF_SOURCE_NAMESPACE_ID!,
  secret: process.env.AUTH_SECRET!,
  adapter,
});

const nextAuth = NextAuth({
  adapter,
  providers: [
    // Your OAuth providers, and an email provider:
    {
      id: "email",
      type: "email",
      name: "Email",
      sendVerificationRequest: refref.sendVerificationRequest(sendMagicLink),
    },
  ],
  events: refref.events,
});

export const handlers = refref.handlers(nextAuth.handlers);
export const { auth, signIn, signOut } = nextAuth;

Keep the API key on your server. The recipe names each new user as { kind: "individual", externalId: user.id }; pass participant to name another one.

What it does

  • A new account: Auth.js reports a new user once, in its signIn event, when it creates a user with an OAuth provider or a magic link. The recipe sends the signup and the browser's handle from the cookie.
  • A magic link opened on another device: when the link is sent to an email that has no account yet, the recipe connects the browser's click to that one link, for as long as the link is valid. When the link creates the account, on any device, the click counts.
  • Nothing else: a sign-in, linking another provider to an account, or a user that your code creates through the adapter sends nothing.

Failures

A RefRef failure never fails a sign-in. The recipe logs it. Send the same calls again later: a repeated signup returns the first result.

NextAuth.js 4

The recipe is for Auth.js 5 (next-auth 5). It does not work with NextAuth.js 4 as written.

The recipe file

refref.ts
/**
 * The RefRef recipe for Auth.js (decision 0065). Copy this file into an
 * Auth.js application. It needs no table and stores no referral state:
 *
 * - `refref.handlers(handlers)` keeps the current request, so the hooks below
 *   can read the browser's `refref_handle` cookie and the callback URL.
 * - `refref.events.signIn` sends the signup when Auth.js reports a new user
 *   (`isNewUser`, OAuth or email), with the browser's handle. For a magic
 *   link opened in another browser, it first resolves the reference that the
 *   starting browser made. It does not use `createUser`, which Auth.js also
 *   calls for an existing user under `allowDangerousEmailAccountLinking`.
 * - `refref.sendVerificationRequest(send)` wraps the email provider's sender:
 *   for an email that has no account yet, it identifies the browser's handle
 *   to a reference keyed on the link's token.
 */
import { createHmac } from "node:crypto";
import { AsyncLocalStorage } from "node:async_hooks";
import type { Adapter } from "next-auth/adapters";

type Handler<R extends Request> = (request: R) => Promise<Response>;
interface Participant {
  kind: "individual" | "group";
  externalId: string;
}
export interface RefRefOptions {
  apiUrl: string;
  apiKey: string;
  projectId: string;
  /** The source namespace of the app (`POST /v1/sources`). */
  sourceNamespaceId: () => Promise<string>;
  /** A server secret for the reference keys, for example AUTH_SECRET. */
  secret: string;
  adapter: Pick<Adapter, "getUserByEmail">;
  /** The participant of a new user. The default is the user. */
  participant?: (user: { id: string }) => Participant;
}

/** A RefRef call that fails. A 404 or 409 of an identification (an unknown
 * or already identified handle, an expired reference) is not a failure. */
class RefRefError extends Error {
  constructor(
    readonly path: string,
    readonly status: number,
  ) {
    super(`RefRef ${path} failed with status ${status}`);
  }
}

export function createRefRef(options: RefRefOptions) {
  const requests = new AsyncLocalStorage<Request>();
  const participantOf =
    options.participant ??
    ((user: { id: string }) => ({
      kind: "individual" as const,
      externalId: user.id,
    }));

  async function call(path: string, body: object) {
    const response = await fetch(
      `${options.apiUrl.replace(/\/$/, "")}${path}`,
      {
        method: "POST",
        headers: {
          "Content-Type": "application/json",
          "X-Api-Key": options.apiKey,
        },
        body: JSON.stringify({ projectId: options.projectId, ...body }),
      },
    );
    const refused =
      path === "/v1/identify" &&
      (response.status === 404 || response.status === 409);
    if (!response.ok && !refused) throw new RefRefError(path, response.status);
  }

  const handleOf = (request: Request | undefined) =>
    request?.headers
      .get("cookie")
      ?.split(/;\s*/)
      .find((cookie) => cookie.startsWith("refref_handle="))
      ?.slice("refref_handle=".length) || undefined;
  /** The key of a magic link: an HMAC of its token, so RefRef never sees it. */
  const referenceKey = (token: string) =>
    createHmac("sha256", options.secret)
      .update(`authjs-email\n${token}`)
      .digest("hex");

  return {
    handlers: <R extends Request>(handlers: {
      GET: Handler<R>;
      POST: Handler<R>;
    }) => ({
      GET: (request: R) => requests.run(request, () => handlers.GET(request)),
      POST: (request: R) => requests.run(request, () => handlers.POST(request)),
    }),

    events: {
      async signIn({
        user,
        isNewUser,
      }: {
        user: { id?: string };
        isNewUser?: boolean;
      }) {
        if (!isNewUser || !user.id) return;
        const request = requests.getStore();
        const participant = participantOf({ id: user.id });
        // A magic link names its token in the callback URL. Auth.js signs a
        // new user in only after it consumed that token.
        const token = request && new URL(request.url).searchParams.get("token");
        try {
          if (token)
            await call("/v1/identify", {
              reference: { key: referenceKey(token) },
              participant,
            });
          const handle = handleOf(request);
          await call("/v1/track/events", {
            sourceNamespaceId: await options.sourceNamespaceId(),
            type: "signup",
            participant,
            ...(handle && { identify: { handle } }),
          });
        } catch (error) {
          // Sign-in never fails for RefRef. Log, and send the same calls
          // again later: a repeated signup returns the first result.
          console.error(error);
        }
      },
    },

    sendVerificationRequest<
      P extends {
        identifier: string;
        token: string;
        expires: Date;
        request: Request;
      },
    >(send: (params: P) => Promise<void>) {
      return async (params: P) => {
        const handle = handleOf(params.request);
        // A link for an existing account is a sign-in, not a signup.
        const existing = await options.adapter.getUserByEmail?.(
          params.identifier,
        );
        if (handle && !existing)
          await call("/v1/identify", {
            handle,
            // The reference lives as long as the link, at most 90 days.
            reference: {
              key: referenceKey(params.token),
              expiresAt: new Date(
                Math.min(params.expires.getTime(), Date.now() + 90 * 86400000),
              ).toISOString(),
            },
          }).catch((error: unknown) => console.error(error));
        await send(params);
      };
    },
  };
}

Check your integration

TestExpected result
A shares a link; B follows it and signs up with GitHubB's signup has a referral from A
B asks for a magic link on a laptop and opens it on a phoneB's signup has a referral from A
B signs in againNo second signup
An existing user follows a referral link and signs inNo referral

On this page