Server-resolved user IDs
Attach the signed-in user to every event from your server, where a browser cannot forge it,
instead of passing a user ID through track().
Whatever the browser passes to track() is claimed:
anyone can call it from the browser console, or post to your collector
directly, with any user ID they like. The Hub runs in your server, where it
can read the visitor's session itself. What it adds goes into the record's hubProperties, apart
from what the browser sent.
Add the user in hubProperties
The Hub's hubProperties function runs once for every event, from the browser and from your server
alike. Look up the signed-in user there. In Next.js, it reads the request with cookies() and
headers() from next/headers.
Next.js · lib/eventrail-hub.ts
import "server-only";
import { cookies } from "next/headers";
import { createHub } from "@eventrail/nextjs/server";
import { createConsoleDestination } from "@eventrail/nextjs/destinations/console";
import { getUserIdFromSession } from "@/lib/auth"; // your existing auth
export const hub = createHub({
destinations: [createConsoleDestination()],
hubProperties: async () => {
const session = (await cookies()).get("session")?.value;
return { userId: session ? await getUserIdFromSession(session) : null };
},
});- Read the request before the first
await. In Next.js, a server event can be handed over just as its request finishes, after which Next.js no longer serves request data. Above,cookies()is called before anything is awaited. - A failure drops the event. If
hubPropertiesthrows, for example because your session store is unreachable, the event is dropped and the error goes to the Hub'sonError. The code that calledtrack()is not affected. - It records the user, it doesn't turn anyone away. Events from signed-out visitors are still
accepted, with
userIdset tonull. To accept only signed-in users, see Authenticated users only.
Say a visitor signed in as usr_42 runs track("page_view", { userId: "usr_7" }) in the browser
console. The record keeps both values, each in its own place (other fields left out):
{
producerProperties: { userId: "usr_7" },Claimed by the browser: anyone can send any value.
hubProperties: {
userAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36",
forwardedFor: "203.0.113.7",
acceptLanguage: "en-US,en;q=0.9",
userId: "usr_42"Resolved by your server from the session cookie.
}
}Full records are on EventRecord.
With an auth library
In Next.js, with one of the common auth libraries, hubProperties calls the library's own session
check. Each example is a complete lib/eventrail-hub.ts.
Auth.js
import "server-only";
import { createHub } from "@eventrail/nextjs/server";
import { createConsoleDestination } from "@eventrail/nextjs/destinations/console";
import { auth } from "@/auth"; // your Auth.js config
export const hub = createHub({
destinations: [createConsoleDestination()],
hubProperties: async () => {
const session = await auth();
return { userId: session?.user?.id ?? null };Auth.js leaves the id out of the session until your session callback adds it.
},
});Clerk
import "server-only";
import { createHub } from "@eventrail/nextjs/server";
import { createConsoleDestination } from "@eventrail/nextjs/destinations/console";
import { auth } from "@clerk/nextjs/server";auth() only works where clerkMiddleware() runs. Its default matcher includes API routes, so the collector route is covered.
export const hub = createHub({
destinations: [createConsoleDestination()],
hubProperties: async () => {
const { userId } = await auth();Clerk reads the request after its own first await. Events from route handlers and server actions are safe; one fired as a Server Component finishes rendering can be dropped.
return { userId };
},
});Better Auth
import "server-only";
import { headers } from "next/headers";
import { createHub } from "@eventrail/nextjs/server";
import { createConsoleDestination } from "@eventrail/nextjs/destinations/console";
import { auth } from "@/lib/auth"; // your Better Auth config
export const hub = createHub({
destinations: [createConsoleDestination()],
hubProperties: async () => {
const session = await auth.api.getSession({ headers: await headers() });
return { userId: session?.user.id ?? null };
},
});Supabase
import "server-only";
import { createHub } from "@eventrail/nextjs/server";
import { createConsoleDestination } from "@eventrail/nextjs/destinations/console";
import { createClient } from "@/lib/supabase/server"; // your Supabase server client
export const hub = createHub({
destinations: [createConsoleDestination()],
hubProperties: async () => {
const supabase = await createClient();
const { data } = await supabase.auth.getClaims();
return { userId: data?.claims.sub ?? null };
},
});Events without a signed-in user
Not every event has a signed-in user behind it. With the hubProperties above:
| Event | userId |
|---|---|
A click from a visitor signed in as usr_42 | "usr_42" |
| A server action that visitor triggers | "usr_42": it runs in their request |
| A click from a signed-out visitor | null |
| A webhook, or a cron job that calls one of your routes | null: the request has no session cookie |
| A queue worker or a script | No record: the event is dropped |
Signed-out visitors keep their sessionId when they sign in, as long as the
storage policy stores one. Events from before sign-in can then
be related to the user through it:
name | sessionId | userId |
|---|---|---|
page_view | 9d2c7e4b-1f8a-4c3d-8e2b-7a6f5e4d3c2b | null |
plan_upgraded | 9d2c7e4b-1f8a-4c3d-8e2b-7a6f5e4d3c2b | "usr_42" |
Outside a request, such as in a queue worker or a script, there is no request to read. In
Next.js, cookies() throws there, so hubProperties throws and every event is dropped. If you
fire events there, catch it:
Next.js · lib/eventrail-hub.ts
import "server-only";
import { cookies } from "next/headers";
import { createHub } from "@eventrail/nextjs/server";
import { createConsoleDestination } from "@eventrail/nextjs/destinations/console";
import { getUserIdFromSession } from "@/lib/auth"; // your existing auth
export const hub = createHub({
destinations: [createConsoleDestination()],
hubProperties: async () => {
let cookieStore;
try {
cookieStore = cookies();
} catch {
return { userId: null };No request, so nobody is signed in.
}
const session = (await cookieStore).get("session")?.value;
return { userId: session ? await getUserIdFromSession(session) : null };
},
});With an auth library, its session check throws outside a request as well. Keep the try around
cookies(), and call the library after it. Catching the library's own errors instead would also
hide real failures, such as an unreachable session store, as signed-out events.