From 3168e91618fd52a84cbc4faa5b1f1615d97920f0 Mon Sep 17 00:00:00 2001
From: Matt Jenkinson <75292329+mattdjenkinson@users.noreply.github.com>
Date: Mon, 11 May 2026 15:29:42 +0200
Subject: [PATCH] feat: capture MaxMind tracking token on session metadata at
signup
Move the browser-side device fingerprint out of user metadata and onto
the Zitadel session created during signup. Device fingerprinting is
intrinsically session-scoped: a single user can have many sessions
across many devices, and keeping the token on the session avoids
polluting a long-lived User resource with a transient signal.
Key features/changes:
- New client component MaxMindTracker initialises window.__mmapiws,
injects device.js once, and polls the __mmapiwsid cookie until the
token is available, persisting it to sessionStorage so the value
survives the /register -> /register/password route transition
- Layout gates the tracker on NEXT_PUBLIC_MAXMIND_ACCOUNT_ID, mirroring
the existing Fathom and Marker.io toggles, so dev and preview
deployments never contact MaxMind
- All three register forms (password, passkey-only, IDP-incomplete)
read the token from sessionStorage and pass it through registerUser
/ registerUserAndLinkToIDP
- registerUser / registerUserAndLinkToIDP forward the token as Zitadel
session metadata (key maxmind/tracking-token) via the new metadata
parameter on createSessionAndUpdateCookie /
createSessionForIdpAndUpdateCookie / createSessionFromChecks /
createSessionForUserIdAndIdpIntent. addHumanUser is unchanged
- Document NEXT_PUBLIC_MAXMIND_ACCOUNT_ID in next-env-vars.d.ts
auth-provider-zitadel's session apiserver surfaces the metadata entry
on the milo Session annotation iam.miloapis.com/maxmind-tracking-token
which the fraud service reads when constructing the minFraud request.
Token capture is best-effort: if device.js hasn't returned the cookie
before submit, the user proceeds without it and the fraud check falls
back to IP/email/UA signals only.
---
apps/login/next-env-vars.d.ts | 7 ++
apps/login/src/app/(main)/layout.tsx | 7 ++
.../components/maxmind/maxmind-tracker.tsx | 98 +++++++++++++++++++
.../register-form-idp-incomplete.tsx | 2 +
apps/login/src/components/register-form.tsx | 2 +
.../components/set-register-password-form.tsx | 2 +
apps/login/src/lib/server/cookie.ts | 13 +++
apps/login/src/lib/server/register.ts | 25 +++++
apps/login/src/lib/zitadel.ts | 32 +++++-
9 files changed, 187 insertions(+), 1 deletion(-)
create mode 100644 apps/login/src/components/maxmind/maxmind-tracker.tsx
diff --git a/apps/login/next-env-vars.d.ts b/apps/login/next-env-vars.d.ts
index 3cbc79bb71..2dbcd1aad1 100644
--- a/apps/login/next-env-vars.d.ts
+++ b/apps/login/next-env-vars.d.ts
@@ -44,5 +44,12 @@ declare namespace NodeJS {
* Optional: the Fathom analytics id
*/
FATHOM_ID?: string;
+
+ /**
+ * Optional: the MaxMind minFraud account id used to load the
+ * device-tracking JavaScript snippet at signup. When unset, the
+ * tracker component is not rendered and no token is collected.
+ */
+ NEXT_PUBLIC_MAXMIND_ACCOUNT_ID?: string;
}
}
diff --git a/apps/login/src/app/(main)/layout.tsx b/apps/login/src/app/(main)/layout.tsx
index 0ef78080ec..2af2e46350 100644
--- a/apps/login/src/app/(main)/layout.tsx
+++ b/apps/login/src/app/(main)/layout.tsx
@@ -7,6 +7,7 @@ import { FathomAnalytics } from "@/components/fathom/fathom";
import { LanguageProvider } from "@/components/language-provider";
import { Loader } from "@/components/loader";
import MarkerIoEmbed from "@/components/markerio/markerio";
+import { MaxMindTracker } from "@/components/maxmind/maxmind-tracker";
import { ThemeProvider } from "@/components/theme-provider";
import { SITE_CONFIG } from "@/config/site";
import { alliance, canelaText, frontliner } from "@/lib/fonts/fonts";
@@ -64,6 +65,12 @@ export default async function RootLayout({
+ {process.env.NEXT_PUBLIC_MAXMIND_ACCOUNT_ID && (
+
+ )}
+
{process.env.MARKER_IO_PROJECT_ID && (
)}
diff --git a/apps/login/src/components/maxmind/maxmind-tracker.tsx b/apps/login/src/components/maxmind/maxmind-tracker.tsx
new file mode 100644
index 0000000000..f1c526e413
--- /dev/null
+++ b/apps/login/src/components/maxmind/maxmind-tracker.tsx
@@ -0,0 +1,98 @@
+"use client";
+
+import { useEffect } from "react";
+
+/**
+ * sessionStorage key under which we stash the MaxMind device-tracking token
+ * captured by device.js. The register form reads it just before submitting so
+ * the value can be forwarded to Zitadel as session metadata.
+ */
+export const MAXMIND_TOKEN_STORAGE_KEY = "datum.maxmind.trackingToken";
+
+/**
+ * Cookie set by MaxMind's device.js after a successful fingerprint exchange.
+ * The cookie value is the tracking token; we mirror it into sessionStorage so
+ * we don't have to parse document.cookie at submit time.
+ */
+const MAXMIND_COOKIE_NAME = "__mmapiwsid";
+
+function readMaxMindCookie(): string {
+ if (typeof document === "undefined") return "";
+ const prefix = `${MAXMIND_COOKIE_NAME}=`;
+ for (const part of document.cookie.split(";")) {
+ const trimmed = part.trim();
+ if (trimmed.startsWith(prefix)) {
+ return decodeURIComponent(trimmed.slice(prefix.length));
+ }
+ }
+ return "";
+}
+
+/**
+ * MaxMindTracker loads the MaxMind minFraud device.js snippet on mount and
+ * polls for the resulting __mmapiwsid cookie, mirroring it into
+ * sessionStorage. Rendering is gated by NEXT_PUBLIC_MAXMIND_ACCOUNT_ID — if
+ * unset the component is a no-op, so dev and preview deployments never
+ * contact MaxMind.
+ */
+export function MaxMindTracker({ accountId }: { accountId: string }) {
+ useEffect(() => {
+ if (!accountId) return;
+ if (typeof window === "undefined") return;
+
+ const w = window as unknown as {
+ __mmapiws?: { accountId?: string };
+ };
+ w.__mmapiws = w.__mmapiws || {};
+ w.__mmapiws.accountId = accountId;
+
+ // Load device.js (idempotent — guard against StrictMode double-mount).
+ if (!document.querySelector('script[data-maxmind="device"]')) {
+ const script = document.createElement("script");
+ script.src = "https://device.maxmind.com/js/device.js";
+ script.async = true;
+ script.dataset.maxmind = "device";
+ document.body.appendChild(script);
+ }
+
+ // Poll briefly for the tracking-token cookie. device.js sets it after
+ // an async fingerprint exchange, typically within a few hundred ms.
+ let attempts = 0;
+ const maxAttempts = 30; // ~6 seconds at 200ms cadence
+ const handle = window.setInterval(() => {
+ attempts++;
+ const token = readMaxMindCookie();
+ if (token) {
+ try {
+ window.sessionStorage.setItem(MAXMIND_TOKEN_STORAGE_KEY, token);
+ } catch {
+ // sessionStorage may be disabled (private mode). Best-effort only.
+ }
+ window.clearInterval(handle);
+ return;
+ }
+ if (attempts >= maxAttempts) {
+ window.clearInterval(handle);
+ }
+ }, 200);
+
+ return () => window.clearInterval(handle);
+ }, [accountId]);
+
+ return null;
+}
+
+/**
+ * Returns the MaxMind device-tracking token captured during the session, or
+ * undefined when none is available (sessionStorage blocked, MaxMind script
+ * not yet finished, or NEXT_PUBLIC_MAXMIND_ACCOUNT_ID unset).
+ */
+export function readMaxMindTrackingToken(): string | undefined {
+ if (typeof window === "undefined") return undefined;
+ try {
+ const value = window.sessionStorage.getItem(MAXMIND_TOKEN_STORAGE_KEY);
+ return value || undefined;
+ } catch {
+ return undefined;
+ }
+}
diff --git a/apps/login/src/components/register-form-idp-incomplete.tsx b/apps/login/src/components/register-form-idp-incomplete.tsx
index b8a7765c9c..fe7f826d48 100644
--- a/apps/login/src/components/register-form-idp-incomplete.tsx
+++ b/apps/login/src/components/register-form-idp-incomplete.tsx
@@ -8,6 +8,7 @@ import { Alert } from "./alert";
import { BackButton } from "./back-button";
import { Button, ButtonVariants } from "./button";
import { TextInput } from "./input";
+import { readMaxMindTrackingToken } from "./maxmind/maxmind-tracker";
import { Spinner } from "./spinner";
import { Translated } from "./translated";
@@ -71,6 +72,7 @@ export function RegisterFormIDPIncomplete({
organization: organization,
requestId: requestId,
idpIntent: idpIntent,
+ deviceTrackingToken: readMaxMindTrackingToken(),
})
.catch(() => {
setError("Could not register user");
diff --git a/apps/login/src/components/register-form.tsx b/apps/login/src/components/register-form.tsx
index 2ddcfb94fa..60d4b06b92 100644
--- a/apps/login/src/components/register-form.tsx
+++ b/apps/login/src/components/register-form.tsx
@@ -16,6 +16,7 @@ import {
} from "./authentication-method-radio";
import { FormActions } from "./form-actions";
import { TextInput } from "./input";
+import { readMaxMindTrackingToken } from "./maxmind/maxmind-tracker";
import { Translated } from "./translated";
type Inputs =
@@ -68,6 +69,7 @@ export function RegisterForm({
lastName: values.lastname,
organization: organization,
requestId: requestId,
+ deviceTrackingToken: readMaxMindTrackingToken(),
})
.catch(() => {
setError("Could not register user");
diff --git a/apps/login/src/components/set-register-password-form.tsx b/apps/login/src/components/set-register-password-form.tsx
index 39e8708c1a..f31fd837b9 100644
--- a/apps/login/src/components/set-register-password-form.tsx
+++ b/apps/login/src/components/set-register-password-form.tsx
@@ -14,6 +14,7 @@ import { FieldValues, useForm } from "react-hook-form";
import { Alert } from "./alert";
import { FormActions } from "./form-actions";
import { TextInput } from "./input";
+import { readMaxMindTrackingToken } from "./maxmind/maxmind-tracker";
import { PasswordComplexity } from "./password-complexity";
import { Translated } from "./translated";
@@ -64,6 +65,7 @@ export function SetRegisterPasswordForm({
organization: organization,
requestId: requestId,
password: values.password,
+ deviceTrackingToken: readMaxMindTrackingToken(),
})
.catch(() => {
setError("Could not register user");
diff --git a/apps/login/src/lib/server/cookie.ts b/apps/login/src/lib/server/cookie.ts
index 841fc06b3a..5cd1bea5a6 100644
--- a/apps/login/src/lib/server/cookie.ts
+++ b/apps/login/src/lib/server/cookie.ts
@@ -51,6 +51,14 @@ export async function createSessionAndUpdateCookie(command: {
checks: Checks;
requestId: string | undefined;
lifetime?: Duration;
+ /**
+ * Arbitrary key/value pairs to attach to the Zitadel session as
+ * metadata. Today this is how the MaxMind device-tracking token
+ * captured at signup reaches the fraud service: it rides on the
+ * session and is surfaced as an annotation on the milo Session by
+ * the auth-provider-zitadel apiserver.
+ */
+ metadata?: Record;
}): Promise {
const _headers = await headers();
const { serviceUrl } = getServiceUrlFromHeaders(_headers);
@@ -59,6 +67,7 @@ export async function createSessionAndUpdateCookie(command: {
serviceUrl,
checks: command.checks,
lifetime: command.lifetime,
+ metadata: command.metadata,
});
if (createdSession) {
@@ -114,6 +123,7 @@ export async function createSessionForIdpAndUpdateCookie({
idpIntent,
requestId,
lifetime,
+ metadata,
}: {
userId: string;
idpIntent: {
@@ -122,6 +132,8 @@ export async function createSessionForIdpAndUpdateCookie({
};
requestId: string | undefined;
lifetime?: Duration;
+ /** See createSessionAndUpdateCookie. */
+ metadata?: Record;
}): Promise {
const _headers = await headers();
const { serviceUrl } = getServiceUrlFromHeaders(_headers);
@@ -131,6 +143,7 @@ export async function createSessionForIdpAndUpdateCookie({
userId,
idpIntent,
lifetime,
+ metadata,
}).catch((error: ErrorDetail | CredentialsCheckError) => {
console.error("Could not set session", error);
if ("failedAttempts" in error && error.failedAttempts) {
diff --git a/apps/login/src/lib/server/register.ts b/apps/login/src/lib/server/register.ts
index 1824a54ecb..4d1d20d22d 100644
--- a/apps/login/src/lib/server/register.ts
+++ b/apps/login/src/lib/server/register.ts
@@ -22,6 +22,16 @@ import { getNextUrl } from "../client";
import { getServiceUrlFromHeaders } from "../service-url";
import { checkEmailVerification } from "../verify-helper";
+/**
+ * Zitadel session-metadata key carrying the MaxMind minFraud device-tracking
+ * token captured by the browser at signup. auth-provider-zitadel's session
+ * apiserver mirrors this entry onto the milo Session annotation
+ * iam.miloapis.com/maxmind-tracking-token, which the fraud service then
+ * forwards to MaxMind as device.tracking_token. Keep this constant in sync
+ * with the metadataAnnotationKeys allowlist in the Go side.
+ */
+const MAXMIND_TRACKING_TOKEN_METADATA_KEY = "maxmind/tracking-token";
+
type RegisterUserCommand = {
email: string;
firstName: string;
@@ -29,8 +39,19 @@ type RegisterUserCommand = {
password?: string;
organization: string;
requestId?: string;
+ /**
+ * MaxMind device-tracking token from the browser, when available. Attached
+ * to the Zitadel session created during signup; silently dropped if empty.
+ */
+ deviceTrackingToken?: string;
};
+function sessionMetadataFor(
+ token?: string,
+): Record | undefined {
+ return token ? { [MAXMIND_TRACKING_TOKEN_METADATA_KEY]: token } : undefined;
+}
+
export type RegisterUserResponse = {
userId: string;
sessionId: string;
@@ -82,6 +103,7 @@ export async function registerUser(command: RegisterUserCommand) {
lifetime: command.password
? loginSettings?.passwordCheckLifetime
: undefined,
+ metadata: sessionMetadataFor(command.deviceTrackingToken),
});
if (!session || !session.factors?.user) {
@@ -156,6 +178,8 @@ type RegisterUserAndLinkToIDPommand = {
idpUserId: string;
idpId: string;
idpUserName: string;
+ /** See RegisterUserCommand.deviceTrackingToken. */
+ deviceTrackingToken?: string;
};
export type registerUserAndLinkToIDPResponse = {
@@ -210,6 +234,7 @@ export async function registerUserAndLinkToIDP(
userId: addResponse.userId, // the user we just created
idpIntent: command.idpIntent,
lifetime: loginSettings?.externalLoginCheckLifetime,
+ metadata: sessionMetadataFor(command.deviceTrackingToken),
});
if (!session || !session.factors?.user) {
diff --git a/apps/login/src/lib/zitadel.ts b/apps/login/src/lib/zitadel.ts
index 45a0409c2a..82bc6e7de7 100644
--- a/apps/login/src/lib/zitadel.ts
+++ b/apps/login/src/lib/zitadel.ts
@@ -311,21 +311,48 @@ export async function getPasswordComplexitySettings({
return useCache ? cacheWrapper(callback) : callback;
}
+/**
+ * Builds the Zitadel session-metadata payload from a plain string map.
+ * Zitadel stores metadata values as bytes; we encode each value with
+ * TextEncoder so the consumer (auth-provider-zitadel) can string-decode
+ * it back without ambiguity. Returns undefined when the input is empty
+ * so we don't push an empty map onto the gRPC request.
+ */
+function encodeSessionMetadata(
+ metadata?: Record,
+): Record | undefined {
+ if (!metadata) return undefined;
+ const entries = Object.entries(metadata).filter(([, v]) => !!v);
+ if (entries.length === 0) return undefined;
+ const encoder = new TextEncoder();
+ return Object.fromEntries(entries.map(([k, v]) => [k, encoder.encode(v)]));
+}
+
export async function createSessionFromChecks({
serviceUrl,
checks,
lifetime,
+ metadata,
}: {
serviceUrl: string;
checks: Checks;
lifetime?: Duration;
+ metadata?: Record;
}) {
const sessionService: Client =
await createServiceForHost(SessionService, serviceUrl);
const userAgent = await getUserAgent();
- return sessionService.createSession({ checks, lifetime, userAgent }, {});
+ return sessionService.createSession(
+ {
+ checks,
+ lifetime,
+ userAgent,
+ metadata: encodeSessionMetadata(metadata),
+ },
+ {},
+ );
}
export async function createSessionForUserIdAndIdpIntent({
@@ -333,6 +360,7 @@ export async function createSessionForUserIdAndIdpIntent({
userId,
idpIntent,
lifetime,
+ metadata,
}: {
serviceUrl: string;
userId: string;
@@ -341,6 +369,7 @@ export async function createSessionForUserIdAndIdpIntent({
idpIntentToken?: string | undefined;
};
lifetime?: Duration;
+ metadata?: Record;
}) {
const sessionService: Client =
await createServiceForHost(SessionService, serviceUrl);
@@ -359,6 +388,7 @@ export async function createSessionForUserIdAndIdpIntent({
},
lifetime,
userAgent,
+ metadata: encodeSessionMetadata(metadata),
});
}