Olympus Docs
CookbookDefensive security

Notify on new device sign-in

Email user when their account is accessed from an unfamiliar device

"Someone just signed in from a new device" emails are useful security signal, users who didn't initiate the login take action (change password, revoke session).

Approach

Track a fingerprint per device. On login, compare; if new, email.

Device fingerprint

Use a stable-but-not-unique fingerprint:

function fingerprintRequest(req: Request): string {
  const ua = req.headers.get("user-agent") ?? "";
  const accept = req.headers.get("accept") ?? "";
  const lang = req.headers.get("accept-language") ?? "";
  // Hash these together
  return crypto.createHash("sha256").update(`${ua}|${accept}|${lang}`).digest("hex");
}

This gets you "the same browser + OS + language" without invasive client-side fingerprinting libraries. Reliability ~70-80%.

For better accuracy, accept a small client-side library (FingerprintJS, similar) but understand the privacy implications.

Database

CREATE TABLE known_devices (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  identity_id UUID NOT NULL,
  fingerprint TEXT NOT NULL,
  last_seen_at TIMESTAMPTZ NOT NULL,
  first_seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  UNIQUE (identity_id, fingerprint)
);

Logic

async function trackDevice(identityId: string, req: Request) {
  const fp = fingerprintRequest(req);
  const existing = await db`
    SELECT id FROM known_devices
    WHERE identity_id = ${identityId} AND fingerprint = ${fp}
  `.first();

  if (existing) {
    // Familiar device, just update last seen
    await db`UPDATE known_devices SET last_seen_at = NOW() WHERE id = ${existing.id}`;
    return { new: false };
  }

  // New device, record + email
  await db`INSERT INTO known_devices (identity_id, fingerprint, last_seen_at) VALUES (${identityId}, ${fp}, NOW())`;
  const identity = await kratosGetIdentity(identityId);
  const ip = req.headers.get("x-real-ip");
  const geo = await geoLookup(ip);
  await sendSecurityEmail(identity.traits.email, {
    type: "new_device",
    userAgent: req.headers.get("user-agent"),
    ip,
    location: `${geo.city}, ${geo.country}`,
    revokeUrl: `https://your-domain/settings/sessions`,
  });
  return { new: true };
}

Wire as a Kratos hook on login completion (see Cookbook, Custom Kratos webhook).

Email template

Plain HTML email:

<p>Hi <strong>{{name}}</strong>,</p>
<p>Your account was just accessed from a new device:</p>
<ul>
  <li>Time: {{time}}</li>
  <li>Location: {{location}}</li>
  <li>Browser: {{userAgent}}</li>
</ul>
<p>If this was you, no action needed.</p>
<p>If not, <a href="{{revokeUrl}}">revoke this session immediately</a> and change your password.</p>

False positives

  • Browser updates, UA changes; new fingerprint.
  • Switching wifi vs cellular, IP changes (only if you include IP in fingerprint).
  • Private browsing modes, fingerprint may differ.

Acceptable rate ~5-10%. Users learn to expect the occasional alert.

Per-user opt-out

Some users find these emails noisy. Add a setting:

ALTER TABLE user_preferences ADD COLUMN notify_new_device BOOLEAN NOT NULL DEFAULT true;

Toggle in your settings UI.

On this page