πͺ "Remember Me" Checkboxes Are Lying to You: Persistent Sessions Done Right
Every web app has one. That little checkbox. "Remember me on this device." Users love it β nobody wants to log in every morning. Developers add it in an afternoon. Product managers tick it off the sprint.
And security engineers quietly weep.
Here's the thing: "remember me" is one of the most commonly broken authentication features on the web, and not in an obvious way. The app works perfectly. Users stay logged in. Everything looks fine. Until someone's account gets taken over from a laptop they returned two years ago.
Let me show you why, and how to actually fix it.
The Way Everyone Implements It (Wrong)
The naive approach looks like this: when the user ticks "remember me," you take their session cookie β the same one you'd use for a normal login β and you justβ¦ extend its expiry.
// The naive approach β please don't do this
res.cookie('session_id', sessionToken, {
maxAge: 30 * 24 * 60 * 60 * 1000, // 30 days
httpOnly: true,
secure: true,
});
That's it. That's the whole thing. A session token that lasts 30 days instead of a few hours.
The problem? That long-lived cookie is now a 30-day skeleton key to the user's account. If it leaks β via a compromised device, a browser extension gone rogue, a stolen backup, or even an XSS vulnerability β the attacker has month-long access with a single static token. There's no rotation. There's no per-device revocation. There's no audit trail.
At Cubet, we inherited a codebase that did exactly this. The "remember me" tokens didn't rotate on use, didn't expire on suspicious activity, and weren't scoped to a device. When we audited it, we found tokens sitting in our database that hadn't been used in three years but were still technically valid. That's not a session β that's a standing invitation.
The Attack Surface You're Missing
Before the fix, let's be precise about what can go wrong.
Token theft is permanent. With a regular session, theft gives the attacker access until the user logs out or the short expiry hits. With a 30-day static "remember me" token, you've handed them a month. With no rotation, there's nothing to invalidate without invalidating every "remember me" for that user.
No device-level revocation. Users expect "log out everywhere" to work. If you're storing one token per user (or worse, deriving it from their password hash), you can't log out one device without logging out all of them.
Database compromise multiplies. If your remember_me_tokens table leaks, every single user with "remember me" checked is now compromised β indefinitely β unless you rotate all tokens immediately.
The Right Model: Split-Token with Rotation
The fix comes from a 2015 paper by Barry Jaspan that most people have never read, but should be the default implementation everywhere. The idea: split the token into a selector and a validator.
remember_me = base64(selector) + ":" + base64(validator)
Store this in your database:
| column | value |
|---|---|
selector |
random 16 bytes, stored plaintext (used for lookup) |
validator_hash |
bcrypt/SHA-256 of the validator (never stored plaintext) |
user_id |
foreign key |
expires_at |
timestamp |
last_used_at |
for anomaly detection |
When the user returns with their "remember me" cookie:
- Parse out the
selectorandvalidator. - Look up the row by
selector(fast indexed lookup β no full table scan). - Hash the incoming
validatorand compare it againstvalidator_hash. - If it matches: rotate both values, issue a new cookie, log the use.
- If the
selectormatches but the hash doesn't: someone is replaying a stolen old token. Invalidate the entire series for this user and alert them.
That last step is the secret sauce. Because you rotate on every use, an attacker can only use a stolen token once before it becomes stale. And if they use it first? You detect the theft on the legitimate user's next visit.
async function consumeRememberMeToken(cookieValue) {
const [selectorB64, validatorB64] = cookieValue.split(':');
const selector = Buffer.from(selectorB64, 'base64');
const validator = Buffer.from(validatorB64, 'base64');
const row = await db.query(
'SELECT * FROM remember_me_tokens WHERE selector = $1 AND expires_at > NOW()',
[selector]
);
if (!row) return null; // token not found or expired
const validatorHash = await bcrypt.hash(validator.toString('hex'), 12);
const isValid = await bcrypt.compare(validator.toString('hex'), row.validator_hash);
if (!isValid) {
// Selector matched but hash didn't β stolen token in use
await db.query(
'DELETE FROM remember_me_tokens WHERE user_id = $1',
[row.user_id]
);
await alertUserOfSuspiciousActivity(row.user_id);
return null;
}
// Valid β rotate immediately
const newSelector = crypto.randomBytes(16);
const newValidator = crypto.randomBytes(32);
const newHash = await bcrypt.hash(newValidator.toString('hex'), 12);
await db.query(
`UPDATE remember_me_tokens
SET selector = $1, validator_hash = $2, last_used_at = NOW()
WHERE id = $3`,
[newSelector, newHash, row.id]
);
const newToken = newSelector.toString('base64') + ':' + newValidator.toString('base64');
return { userId: row.user_id, newToken };
}
Yes, it's more code than setting maxAge. But it's the difference between a feature and a vulnerability.
Cookie Attributes Still Matter
Even with the split-token model, your cookie attributes need to be right:
res.cookie('remember_token', tokenValue, {
httpOnly: true, // no JS access
secure: true, // HTTPS only
sameSite: 'Lax', // CSRF mitigation
maxAge: 30 * 24 * 60 * 60 * 1000,
path: '/',
});
SameSite: 'Lax' is the minimum β it stops the cookie from being sent on cross-site POST requests (the classic CSRF vector). If your app never needs cross-site embeds, 'Strict' is even better, though it breaks OAuth redirects in some flows.
One more thing: when the user explicitly logs out, delete the token from your database. Setting maxAge: 0 on the cookie is client-side only β a savvy attacker who captured the token value can still replay it. Server-side deletion is the only real logout.
Per-Device Revocation and the "Active Sessions" Page
Once you have one token row per device, "log out everywhere" becomes a one-line DELETE WHERE user_id = ?. You can also build the "active sessions" page that users actually trust: showing the device name (from User-Agent), last seen timestamp, and a "revoke this device" button β all driven by rows in your tokens table.
We added this at Cubet after a user reported that their ex-partner was still logged into their account. Without per-device tracking, we'd have had to invalidate all their sessions. With it, we could surgically revoke just the one device. That's the difference between a security feature and a security theatre prop.
The Checklist
Before you ship that "remember me" feature:
- Tokens are random (CSPRNG), not derived from passwords or user IDs
- Validators are hashed before storage (SHA-256 minimum, bcrypt preferred)
- Tokens rotate on every successful use
- Stolen-token detection (selector match + hash mismatch) triggers invalidation and user alert
- Logout deletes the database row, not just the cookie
- Tokens have a hard expiry (30 days max)
- Cookie has
HttpOnly,Secure, andSameSiteset
"Remember me" is a trust feature. Users check that box because they trust your app with a long-lived credential. Build it so that trust is warranted.
Building something with persistent sessions? Spotted a gap in your existing implementation? Drop me a note on Twitter/X or connect on LinkedIn β always happy to talk auth.