Crossdeck Docs
Dashboard

Identify users — connect Crossdeck to your auth provider

Bolt-on Read time: ~10 min · SDK: @cross-deck/web · Updated May 18, 2026

The SDK auto-captures sessions, page views, clicks, Web Vitals, and uncaught errors the moment you call init() — but every event is attached to an anonymous device ID. To turn that anonymous timeline into an identified user, you call Crossdeck.identify(userId) with the same stable ID your auth provider already gave you. This page is the bolt-on: it takes you from "the SDK is installed but every visitor is anonymous" to "every signed-in user is identified, the dashboard People page shows them by name, and the timeline before they signed up is still attached" in under ten minutes.

TL;DR

Does my app need this?

Quick triage before you read the rest. identify() only matters for apps that sign users in. If yours doesn't, you can stop here.

The hybrid case (public marketing pages + an authenticated app, often two Crossdeck apps on the same project) is handled by treating each app on its own merits — read this doc for the authenticated one, ignore it for the marketing one.

Why identify is manual

The SDK is aggressive about doing things for you. It auto-emits page.viewed on every pushState. It pins a session to a 30-minute idle window. It captures every uncaught window.onerror. It wraps fetch and XMLHttpRequest to log HTTP 5xx as error.http. So why doesn't it auto-detect the signed-in user too?

Because "who is signed in" is not knowable from inside a generic browser SDK. The shape of "the current user" depends entirely on which auth provider you wired:

An SDK that "auto-detected" any of these would silently mis-identify users in the other six, or call identify() too early on a half-authed state, or worst — call identify() with the wrong ID from a stale localStorage cache during the brief window before the auth provider rehydrated. We don't ship that ambiguity.

Identify is intentional, not magic.

One line of code, wired exactly once, at the exact moment your auth provider tells you the user is real. That intentionality is the contract. The SDK cannot pick the right user ID for you — but it can guarantee that the one you pass is the one every subsequent event, error, and entitlement check is attached to.

The alias model — what identify() actually does

Crossdeck tracks two identity axes inside the SDK:

A third value — crossdeckCustomerId (the cdcust_… handle) — is the Crossdeck-side primary key. The backend resolves it from the other two via an identity-graph merge and the SDK persists it locally so subsequent boots can read entitlements directly without a round-trip alias call.

What happens when identify() fires on an anon session

Imagine a typical visitor flow. Day 1: a visitor lands on your marketing site from a Google Ad with gclid=abc. The SDK mints anon_lr9k82xxyy. Over the next four minutes the SDK auto-captures session.started, three page.viewed events, two element.clicked events, and one webvitals.lcp. All attached to anon_lr9k82xxyy. The visitor closes the tab.

Day 3: the same visitor (same device, same browser) comes back. The SDK reads anon_lr9k82xxyy from localStorage and continues attaching events to it. They sign up. Your auth provider fires its "signed in" callback with user_847. You call:

await Crossdeck.identify("user_847", {
  email: "[email protected]",
  traits: { name: "Wes" },
});

Why plan isn't a trait. A customer's plan is an entitlement, kept current by the payment rail — not a string you maintain by hand. See What NOT to call instead below for the full rule. The example above carries only name deliberately; pushing a plan field here would create exactly the drift the rest of the doc tells you to avoid.

The SDK POSTs to /identity/alias with both IDs. The server:

The SDK persists the crossdeckCustomerId locally. From this moment on, every event carries developerUserId: "user_847" and resolves to the same cdcust_…. Funnels stay intact across the sign-up boundary.

What reset() does to the alias

Crossdeck.reset() breaks the link cleanly. The cached crossdeckCustomerId is wiped, the developerUserId is cleared, a new anonymousId is minted (so the next pre-login session is a fresh identity graph entry), super-properties / groups / breadcrumbs / error context are all cleared. Server-side, the previous cdcust_… and user_847 stay linked forever — reset is a client-side wipe, not a server-side unlink.

Wire it up by provider

One subsection per provider. The pattern is always the same shape: inside the auth-state listener your provider gives you, mirror the user into Crossdeck.identify(userId, { email, traits }) when signed in and Crossdeck.reset() when signed out. The code below is what to drop in. Replace the placeholder app ID and key with the real ones from your Apps page.

Every snippet below is a recommended pattern, not auto-detected.

Crossdeck doesn't ship provider-specific subpackages. Each snippet is derived from that provider's own contract (Firebase's onAuthStateChanged, Supabase's onAuthStateChange, Auth0's useAuth0 hook, Clerk's useUser, and so on). The shape is portable — if your auth library exposes any "user signed in / out" event with a stable user ID, the same wiring works. The provider versions below are the ones we've vetted; adapt the import line and the event source as needed.

React shortcut — <CrossdeckProvider>

If your app is React (Next.js, Vite, Remix, CRA — anything that mounts React components), there's a shorter path. @cross-deck/[email protected]+ ships a CrossdeckProvider component at @cross-deck/web/react that handles init(), identify(), and reset() internally. You pass your auth library's user ID as a prop; the provider mirrors it into the SDK on mount and on every change. No useEffect boilerplate, no loading-state guard — eight lines instead of forty.

// app/providers.tsx (Next.js) — or src/main.tsx (Vite / CRA)
"use client"
import { CrossdeckProvider } from "@cross-deck/web/react"
import { useSession } from "next-auth/react"

export function Providers({ children }: { children: React.ReactNode }) {
  const { data: session } = useSession()
  return (
    <CrossdeckProvider
      appId="app_web_xxxxxxxxxxxx"
      publicKey="cd_pub_test_…"
      environment="sandbox"
      userId={session?.user?.id}
    >
      {children}
    </CrossdeckProvider>
  )
}

Swap the useSession hook for whatever your auth library exposes — useAuth0().user?.sub, useUser().user?.id (Clerk), session?.user?.id (Supabase), user?.uid (Firebase). The provider's contract is "whatever you put in userId becomes the identified user; undefined means logged out." Idempotent across React 18 StrictMode re-mounts; SSR-safe (every side effect lives inside useEffect); no DOM wrapper.

The framework-specific subsections below stay accurate and are the right path if you want fine-grained control (passing custom traits, gating on status transitions, or wiring identify from a non-React context like a Node middleware). For most React apps, the provider above is the one-line answer.

Firebase Auth

Drop the listener anywhere you have the auth instance — typically your root provider, layout file, or main entry. onAuthStateChanged fires once on boot with the rehydrated user (or null) and again on every sign-in / sign-out. No useEffect needed — it's a plain callback.

// Firebase Auth — anywhere with the auth instance.
// onAuthStateChanged is a callback, no await/effect needed.
onAuthStateChanged(auth, (user) => {
  if (user) {
    Crossdeck.identify(user.uid, {
      email: user.email,
      traits: { name: user.displayName },
    })
  } else {
    Crossdeck.reset() // call on logout
  }
})

Where it goes. If you have a top-level Firebase initialisation file (src/lib/firebase.ts), put it right under the getAuth() call. In Next.js with the App Router, put it inside your client-component provider's useEffect with no dependencies — the listener installs once on mount.

What happens. First fire is the rehydration: if Firebase persisted a user, you get the real user.uid and identify resolves the customer. If no user, the reset() branch is a no-op on an already-anonymous device. Subsequent fires reflect actual sign-in / sign-out clicks. Firebase Auth deduplicates rehydration so you won't get a spurious identify-then-reset flicker.

Supabase Auth

Same shape as Firebase. onAuthStateChange is the listener; session.user.id is the stable Supabase user UUID.

// Supabase — anywhere with the supabase client. The
// onAuthStateChange callback receives session updates.
supabase.auth.onAuthStateChange((event, session) => {
  if (session?.user?.id) {
    Crossdeck.identify(session.user.id, {
      email: session.user.email,
      traits: { name: session.user.user_metadata?.full_name },
    })
  } else {
    Crossdeck.reset()
  }
})

Where it goes. Right where you create the Supabase client (src/lib/supabase.ts or equivalent). The listener returns an unsubscribe function — in long-lived app shells you don't need it; in HMR-aware dev setups you may want to capture and call it on module dispose.

What happens. Supabase fires INITIAL_SESSION on hydration, then SIGNED_IN / SIGNED_OUT on auth events. All three pass through the same branching above. The Supabase UUID maps 1:1 to your Crossdeck developerUserId.

NextAuth (App Router)

NextAuth is a React-context system, not a callback, so the wiring lives inside a client component with a useEffect. There's one prerequisite: NextAuth's default Session type doesn't expose session.user.id — you extend it once in your auth.ts callbacks so it carries the stable database user ID. This is the canonical NextAuth pattern, recommended by NextAuth itself for any app that needs a per-user identifier beyond email (which can change).

// auth.ts — extend the session ONCE so session.user.id is the
// stable database user ID. Recommended by NextAuth and required
// for any app that treats users as more than their email.
import NextAuth from "next-auth"

export const { handlers, auth } = NextAuth({
  callbacks: {
    async session({ session, token }) {
      if (token?.sub) session.user.id = token.sub
      return session
    },
  },
})

With that one-time extension in place, drop the bridge component near the top of your tree (typically inside app/providers.tsx next to SessionProvider):

// NextAuth — drop in, zero config. Uses session.user.id, the
// stable database ID exposed by the auth.ts callback above.
"use client"
import { useEffect } from "react"
import { useSession } from "next-auth/react"
import { Crossdeck } from "@cross-deck/web"

export function CrossdeckIdentityBridge() {
  const { data: session, status } = useSession()
  useEffect(() => {
    // Skip initial hydration — null data + status:"loading" would
    // briefly call reset() and wipe a persisted identity for ~50ms.
    if (status === "loading") return
    if (session?.user?.id) {
      Crossdeck.identify(session.user.id, {
        email: session.user.email,
        traits: { name: session.user.name },
      })
    } else {
      Crossdeck.reset()
    }
  }, [status, session?.user?.id])
  return null
}

Why not session.user.email as the ID. Emails change — users rebrand, switch providers, hand the account to a colleague. The Crossdeck customer ID needs to be stable for the lifetime of that user, which is why we route through session.user.id (the database primary key) and let email live in the trait/email field where it belongs. If you absolutely can't extend the session type (legacy code, no DB access, a prototype you'll throw away), passing session.user.email in place of session.user.id works as a fallback — but every email change after that will surface as a new customer in Crossdeck.

Why the status === "loading" guard. On boot, NextAuth returns { data: null, status: "loading" } for the first render. Without the guard, you'd call reset() on a freshly-mounted page, wiping a perfectly-good persisted identity for the ~50ms before the session resolves. The guard skips that flicker.

Auth0

Auth0's React SDK exposes the current user via the useAuth0 hook. The pattern matches NextAuth's: a client component with a useEffect that mirrors the hook state into identify() / reset(). The recommended ID field is user.sub — Auth0's canonical, stable subject claim (a string like auth0|abc123 or google-oauth2|xyz depending on the connection).

// Auth0 — recommended pattern from the @auth0/auth0-react hook.
// useAuth0() rehydrates isLoading=true on boot; skip identify
// until isLoading flips to false to avoid a reset() flicker.
"use client"
import { useEffect } from "react"
import { useAuth0 } from "@auth0/auth0-react"
import { Crossdeck } from "@cross-deck/web"

export function CrossdeckIdentityBridge() {
  const { user, isAuthenticated, isLoading } = useAuth0()
  useEffect(() => {
    if (isLoading) return
    if (isAuthenticated && user?.sub) {
      Crossdeck.identify(user.sub, {
        email: user.email,
        traits: { name: user.name },
      })
    } else {
      Crossdeck.reset()
    }
  }, [isLoading, isAuthenticated, user?.sub])
  return null
}

Next.js SDK variant. If your app uses Auth0's Next.js SDK (@auth0/nextjs-auth0) instead of the React SDK, swap useAuth0 for useUser from @auth0/nextjs-auth0/client — the rest of the pattern is identical.

Clerk

Clerk's useUser hook exposes the current user object. user.id is the stable Clerk-side identifier (a string like user_2N3aBcDef…). isLoaded is Clerk's equivalent of NextAuth's status === "loading" — wait for it before identifying.

// Clerk — recommended pattern from @clerk/clerk-react useUser hook.
// Wait for isLoaded before identify/reset to avoid a hydration flicker.
"use client"
import { useEffect } from "react"
import { useUser } from "@clerk/clerk-react"
import { Crossdeck } from "@cross-deck/web"

export function CrossdeckIdentityBridge() {
  const { isLoaded, isSignedIn, user } = useUser()
  useEffect(() => {
    if (!isLoaded) return
    if (isSignedIn && user) {
      Crossdeck.identify(user.id, {
        email: user.primaryEmailAddress?.emailAddress,
        traits: {
          name: user.fullName,
          username: user.username,
        },
      })
    } else {
      Crossdeck.reset()
    }
  }, [isLoaded, isSignedIn, user?.id])
  return null
}

Next.js App Router note. If you're on Clerk's Next.js SDK (@clerk/nextjs), the hook lives at @clerk/nextjs with the same name and shape. Server components can read the user via currentUser() from @clerk/nextjs/server — but identify must run client-side, where the SDK lives.

Custom backend (session cookie + /api/me)

If you run your own auth — session cookies + a /api/me endpoint, or JWTs stored in HttpOnly cookies that get decoded server-side — there's no auth-provider listener to plug into. Instead, fetch the current user once at boot inside an async helper and call identify() when the fetch resolves.

// Custom backend — fetch the current user inside an
// async boot function (or a useEffect in a React component).
// Top-level await is not portable across all bundlers, so wrap.
async function bridgeCrossdeckIdentity() {
  const me = await fetch("/api/me").then((r) => r.json())
  if (me?.userId) {
    await Crossdeck.identify(me.userId, {
      email: me.email,
      traits: { name: me.name },
    })
  } else {
    Crossdeck.reset()
  }
}
bridgeCrossdeckIdentity()

On logout. Whatever code clears your session cookie should also call Crossdeck.reset() — typically your sign-out button's onClick handler, right after the /api/logout response resolves. You don't get an automatic listener like Firebase / Supabase / Clerk — you have to invoke reset() explicitly.

For SPAs that switch users without a full page reload (rare in custom-backend setups), wire a small event emitter at the auth boundary and call identify() / reset() from the same place that updates your in-app user state.

iOS / macOS — native Swift SDK

The Swift SDK (@cross-deck/swift) ships the identical identify(userId:email:traits:) shape as the Web SDK — same vocabulary, same merge contract, same $email-as-anchor semantics. Pattern: wire it inside whichever auth-provider listener your iOS app uses. Same conceptual shape as onAuthStateChanged on Web — just Swift idioms.

// Sign In with Apple — fires from the AuthorizationController delegate
import AuthenticationServices

func authorizationController(controller: ASAuthorizationController, didCompleteWithAuthorization auth: ASAuthorization) {
    guard let credential = auth.credential as? ASAuthorizationAppleIDCredential else { return }
    try? cd.identify(
        userId: credential.user,        // Apple's stable ID — same string across launches
        email: credential.email,        // Present on FIRST authorisation only — persist it
        traits: [
            "name": [credential.fullName?.givenName, credential.fullName?.familyName].compactMap { $0 }.joined(separator: " ")
        ]
    )
}
Sign In with Apple's email + name are first-authorisation-only.

Apple returns email and fullName on the FIRST authorisation only. Subsequent sign-ins return nil for both. Persist these to your backend the first time you see them, and on subsequent sign-ins identify with the persisted values — never assume Apple will hand them back twice.

// Firebase Auth iOS — wire inside the addStateDidChangeListener callback
import FirebaseAuth

Auth.auth().addStateDidChangeListener { _, user in
    guard let user else {
        try? cd.reset()
        return
    }
    try? cd.identify(
        userId: user.uid,           // Same uid the Web Firebase SDK exposes
        email: user.email,
        traits: ["displayName": user.displayName ?? ""]
    )
}
// Auth0 iOS — wire after userInfo resolves with the access token
import Auth0

Auth0.authentication().userInfo(withAccessToken: token).start { result in
    if case .success(let profile) = result {
        try? cd.identify(
            userId: profile.sub,
            email: profile.email,
            traits: ["name": profile.name ?? ""]
        )
    }
}

On logout, call try? cd.reset(). It clears the developerUserId, regenerates the anonymousId, wipes super-properties + breadcrumbs + entitlement cache. The next anonymous session is fully unlinked from the prior identified user.

Where it goes. The Sign In with Apple snippet lives inside your ASAuthorizationControllerDelegate. The Firebase listener belongs in your App.init (SwiftUI) or application(_:didFinishLaunchingWithOptions:) (UIKit) — installs once on app launch, fires for every auth state change for the app's lifetime. The Auth0 example fires whenever you complete a token exchange.

React Native — same shape via @cross-deck/react-native

The React Native SDK (@cross-deck/react-native) uses the same JavaScript shape as the Web SDK — wire inside your auth provider's listener exactly as you would on Web. The signature is identical; the only difference is the import path:

import { cd } from "./crossdeck";
import auth from "@react-native-firebase/auth";

auth().onAuthStateChanged((user) => {
  if (user) {
    cd.identify(user.uid, { email: user.email, traits: { displayName: user.displayName } });
  } else {
    cd.reset();
  }
});

Identity traits — profile data on the customer record

The second argument to identify() is an IdentifyOptions bag with two fields: email and traits. Both flow through to the Crossdeck customer record and become queryable from the dashboard's People page.

await Crossdeck.identify("user_847", {
  email: "[email protected]",
  traits: {
    name: "Wes",
    plan: "pro",
    signedUpAt: "2026-05-11",
    role: "admin",
  },
});

What flows through to the dashboard

How traits are merged

Additive, per-key. Each identify() call merges the supplied keys into the existing customer record. A later identify("user_847", { traits: { plan: "enterprise" } }) updates only plan — it does not wipe name, signedUpAt, or role. To explicitly clear a trait, pass it as null (treated as "stop tracking this key" — the same convention as register() super-properties).

How traits are sanitised

Traits ride through the same validator as track() event properties. The contract is bulletproof — a malformed bag (function, BigInt, circular reference) cannot crash the alias request:

Server-side a second-layer cap applies: max 32 keys, max 1 KB per value, primitives only — nested objects and arrays are dropped silently. If you need richer structures (e.g. an embedded team object), denormalise: teamId + teamName as separate trait keys.

Don't put PII in traits beyond email and display name.

The SDK's PII scrubber auto-strips card-number patterns and most email patterns from event properties. Traits go through the same validator but the intent is "profile metadata that helps you support and segment customers" — not "phone number, physical address, government ID". If a field would land you in a GDPR DPIA, leave it out of traits and store it server-side keyed off the cdcust_… instead.

Reset on logout

Crossdeck.reset() is the bookend to identify(). Call it exactly once, exactly when your auth provider tells you the user signed out. It is synchronous — no await.

Crossdeck.reset();

What reset does

In order:

  1. Emits user.signed_out stamped with the outgoing user's developer ID and cdcust_…, so the Activity stream shows "Wes signed out" rather than an anonymous orphan event.
  2. Uninstalls the auto-tracker and reinstalls a fresh one, so the new session belongs to the new identity (not the old one).
  3. Wipes the identity store — both the anonymousId and the cached crossdeckCustomerId are cleared from localStorage and the 1st-party cookie. A new anonymousId is minted on the spot so the next pre-login session is a fresh identity-graph entry.
  4. Clears the entitlement cacheisEntitled("pro") goes back to false until the next getEntitlements() resolves for the new (or anonymous) user.
  5. Drops the event queue — any in-flight events are discarded rather than sent under the wrong identity.
  6. Clears super-properties, groups, breadcrumbs, error context, error tags — anything identity-scoped. These belong to the old session; they don't carry forward.
  7. Clears the developer user ID — the SDK is now anonymous again, ready for the next identify.

When to call

What NOT to call instead

Pre-auth visits — what happens before identify()

Everything captured before the first identify() call is attached to the anonymous device ID. That's not a degraded state — it's the canonical pre-sign-up timeline, and it gets back-attributed the moment the user identifies.

Concretely, every event captured on the anonymous device carries:

When identify(userId) finally fires, the server's alias merge back-attributes every one of those events to the now-identified customer. Your funnel for "Google Ads → marketing page → /pricing → /signup → subscription" survives the sign-up boundary intact — the Google Ad click is permanently linked to the customer who eventually paid.

This is the headline reason to identify even users with no traits.

Even if you have nothing to pass beyond the user ID itself — no email, no name, no plan — calling identify(userId) on sign-up is what links the marketing-attribution timeline to the customer record. Without it, your acquisition reporting stops at "anonymous device". With it, every dollar of subscription revenue traces back to the original referrer / UTM / click ID that brought the device in.

Server-side identify

Most apps only call identify() from the browser — that's where the anonymous device timeline lives and where the alias merge has the most to attribute. You identify server-side when your server learns who the user is before the browser does: an OAuth callback, an email-link click that resolves identity, or a server-side signup. That is the dedicated server SDK's job — @cross-deck/node — and Server-side events covers the full pattern, including forwarding the browser's anonymousId so the client and server timelines converge on one customer.

import { CrossdeckServer } from "@cross-deck/node";

const crossdeck = new CrossdeckServer({ secretKey: "cd_sk_live_…" });

// The server identify takes BOTH the userId AND the anonymousId —
// a server has no auto-stored device ID, so you pass the one the
// browser minted (forwarded via a cookie or header).
await crossdeck.identify(user.id, anonymousId, {
  email: user.email,
  traits: { name: user.name },
});

Subscription state is not something you push. Do not call identify() from a payment webhook to set a customer's plan. Crossdeck ingests your payment rail directly — once you connect Stripe (or Apple / Google), it receives every subscription event and projects each customer's entitlements automatically. A customer's plan is an entitlement, kept current by the rail — never a trait you maintain by hand.

Testing your wiring

Once the listener is dropped in and your app is running on a real (non-localhost) domain, verify the wiring from the dashboard's People page:

  1. State pill flips from "Anonymous" to "Identified". Before sign-up, your row shows an anon_… ID and a grey "Anonymous" pill. After the first identify() fires, the same row is now keyed by your developer user ID and the pill flips to a green "Identified".
  2. Developer ID column populates. Initially empty for anonymous rows; populated with the string you passed to identify() after the alias lands.
  3. Linked-IDs panel shows both anonymous and developer IDs. Open the customer drawer — the "Identities" panel lists every anon_… the alias merge has linked to this customer, plus the developer ID, plus the cdcust_…. If you sign in from a second device, that device's anon_… also lands on this list after its first identify().
  4. Pre-auth events back-attribute. Open the Activity tab on the customer drawer. The page.viewed and session.started events from before sign-up should now appear with the user's name attached — proof the alias merge ran.
  5. Traits surface in the People row. If you passed email, traits.name, and traits.plan, the People row shows them within ~1s of the alias landing. The drawer's "Traits" section lists every key you've sent.

If any of those don't happen — see Troubleshooting below.

For ad-hoc verification from DevTools, the SDK exposes Crossdeck.diagnostics() in the browser console. The shape:

Crossdeck.diagnostics()
// {
//   started: true,
//   anonymousId: "anon_lr9k82xxyy",
//   crossdeckCustomerId: "cdcust_01HXXX…",  // populated after first identify resolves
//   developerUserId: "user_847",            // populated after first identify resolves
//   sdkVersion: "1.6.3",
//   …
// }

Before identify fires: developerUserId and crossdeckCustomerId are null. After: both populated. That's your single-line proof the wiring is correct.

Troubleshooting

Identify fires before init()CrossdeckError({ code: "not_started" })

The auth listener installed before Crossdeck.init() ran, so the first identify() call hit a not-yet-started SDK. Fix the order: init() first, then install the listener. In a React app, both live inside the same useEffect in your root provider — keep them in that order. In a Firebase-managed init file, call Crossdeck.init() at the top of the module before the onAuthStateChanged registration.

"identify(userId) requires a non-empty userId" thrown

The listener is firing with a falsy user ID. Common causes: NextAuth returning session.user.id as undefined because the type wasn't extended (use session.user.email instead, or wire the auth.ts callback to populate id); Firebase Auth firing pre-rehydration before user.uid resolves (the listener should already handle this with the if (user) guard); a custom backend's /api/me returning { userId: null } for anonymous visitors (let the else branch call reset()).

Traits contain PII the SDK auto-scrubs

If a trait key's value matches the SDK's email or credit-card pattern, the validator emits a debug warning (visible when debug: true is passed at init) and the value passes through unmodified — traits are intentionally less aggressive about scrubbing than event properties, because email as a trait is the legitimate use case. If you're storing values like creditCard: "4111..." in traits, stop — server-side webhooks should write those to your own database keyed off cdcust_… instead.

Multiple identify() calls firing per page

Not harmful — every alias call is idempotent server-side, the same cdcust_… resolves every time. But it's wasted bandwidth and a hint that your listener is wired up too aggressively. Check: is the listener inside a component that re-mounts? Is the useEffect dependency array missing or wrong? Are you calling identify() from both an auth-state listener and a route guard? Pick one.

Never calling reset() on logout

The next visitor on the same device (shared family laptop, public kiosk, ex-employee's machine) will arrive with the previous user's identity persisted. Every event they emit will be attributed to the wrong customer until they sign in themselves. Always pair identify() with reset() in the same listener — the snippets in Wire it up by provider all do this.

Sign-in works but pre-auth events aren't back-attributed

Check that the SDK was actually running before sign-up — open DevTools → Application → Local Storage and look for the crossdeck:anon_id key. If it's missing, the SDK wasn't initialised on the pre-auth pages. If it's present, check that the anonymousId in Crossdeck.diagnostics() matches it (they should). If it does, the alias merge ran — confirm by opening the customer drawer on the People page and looking at the linked-IDs panel; the previous anon_… should be listed there. Pre-auth events tagged with that anon_… are now keyed to the customer.

Identify resolves but People page still shows "Anonymous"

The alias POST /identity/alias request failed silently. Check the Network tab — if you see a 401 / 403, the publishable key is wrong or doesn't match the environment. If you see a 200 but the response has mergePending: true, the server is queuing the merge for a sub-second background job; refresh the People page in a few seconds. If consent has been denied (Crossdeck.consent({ analytics: false }) was called), the SDK returns a synthetic no-op alias result without making the request — re-grant consent if you want identify to actually run.


Bolt-on doc for @cross-deck/[email protected]. The snippets in this page are mirrored from the SDK install-flow generator (_sdk-snippets.js → webIdentifyAuthSnippets()) and locked in by scripts/test-sdk-snippets.mjs — a regression in any provider snippet fails CI.