Tracking Sessions Across Devices
Beyond the session ID
The auth course’s session store is a Map<string, { userId, createdAt }>. It tracks which user owns the session and when it was created. But it does not track where the session is being used.
Users want to know: “Where am I logged in? Is that session on my work laptop or my phone? Is there a session I do not recognize?”
To answer these questions, we store device information with each session.
What to capture
When a session is created, record:
- IP address: Where the login came from. Helps identify location and detect logins from unusual IPs.
- User agent: The browser and OS. Parsed into a human-readable name like “Chrome on macOS.”
- Last active time: When the session was last used. Shows whether a session is actively being used or is stale.
- Created time: When the user logged in. Shows the session’s age.
// src/device-sessions.ts
import db from "./db.js";
import UAParser from "ua-parser-js";
export function createDeviceSession(sessionId: string, userId: string, request: Request): void {
const ip = request.headers.get("x-forwarded-for")?.split(",")[0].trim() ?? "unknown";
const userAgent = request.headers.get("user-agent") ?? "";
const parser = new UAParser(userAgent);
const browser = parser.getBrowser();
const os = parser.getOS();
const deviceName = `${browser.name ?? "Unknown browser"} on ${os.name ?? "Unknown OS"}`;
db.prepare(
"INSERT INTO device_sessions (id, user_id, ip, user_agent, device_name) VALUES (?, ?, ?, ?, ?)",
).run(sessionId, userId, ip, userAgent, deviceName);
}
export function updateLastActive(sessionId: string): void {
db.prepare("UPDATE device_sessions SET last_active_at = datetime('now') WHERE id = ?").run(
sessionId,
);
}
export function getDeviceSessions(userId: string) {
return db
.prepare(
"SELECT id, ip, device_name, last_active_at, created_at FROM device_sessions WHERE user_id = ? ORDER BY last_active_at DESC",
)
.all(userId);
}
export function deleteDeviceSession(sessionId: string, userId: string): boolean {
const result = db
.prepare("DELETE FROM device_sessions WHERE id = ? AND user_id = ?")
.run(sessionId, userId);
return result.changes > 0;
}
export function deleteAllDeviceSessions(userId: string, exceptSessionId?: string): void {
if (exceptSessionId) {
db.prepare("DELETE FROM device_sessions WHERE user_id = ? AND id != ?").run(
userId,
exceptSessionId,
);
} else {
db.prepare("DELETE FROM device_sessions WHERE user_id = ?").run(userId);
}
} Wire it into login
Update the login handler to create a device session alongside the in-memory session:
// In the login handler, after creating the session:
const sessionId = createSession(user.id);
createDeviceSession(sessionId, user.id, c.request); Update last active on each request
In the onRequest hook, update the session’s last active time:
onRequest: ({ request }) => {
const sessionId = getSessionId(request);
if (sessionId) {
updateLastActive(sessionId);
}
return { startTime: Date.now() };
}, This runs on every request. If performance is a concern, update every 5 minutes instead of every request (check the current value before updating).
Exercises
Exercise 1: Log in from two different user agents (use curl -H "User-Agent: ..." with different values). List device sessions and verify both appear with different device names.
Exercise 2: Make a few requests with one session. Check that last_active_at updates.
Exercise 3: What information does ua-parser-js extract? Try parsing several user agent strings. Common browsers, mobile devices, and bots all produce different results.
Why do we store the raw user_agent string in addition to the parsed device_name?