The friction of a login flow is directly proportional to how many users abandon it. Security teams and product teams often frame their goals as opposing forces — every additional security step is friction, and every friction reduction is a security risk. This framing is false in most cases. Good login UX and strong security are mostly compatible, and the places where they genuinely conflict have well-established patterns to minimize the tradeoff. This post covers the specific patterns that matter.
Error messages: don't reveal whether an account exists
When a login attempt fails, the wrong error message gives attackers a free user enumeration service. "That password is incorrect" tells an attacker the account exists. "No account with that email" tells them it doesn't. Both messages help attackers target valid accounts for credential stuffing or build accurate user lists.
The correct error message for any combination of wrong email or wrong password is: "Incorrect email or password." Always the same message, always the same delay. Constant-time comparison for password hashing is important here — if wrong-email responses return faster than wrong-password responses, timing becomes an enumeration vector.
async function handleLogin(email: string, password: string): Promise {
const user = await db.users.findByEmail(email.toLowerCase());
// ALWAYS hash something, even if the user doesn't exist.
// This prevents timing-based email enumeration.
const storedHash = user?.passwordHash ?? DUMMY_HASH;
const passwordValid = await bcrypt.compare(password, storedHash);
if (!user || !passwordValid) {
// Same error, same timing, regardless of which check failed
throw new AuthError('INVALID_CREDENTIALS', 'Incorrect email or password');
}
return user;
}
// Pre-computed dummy hash — bcrypt.compare always takes similar time
const DUMMY_HASH = await bcrypt.hash('dummy_password_for_timing', 10);
Progressive MFA enrollment
Requiring MFA at signup guarantees friction and drop-off. Making MFA entirely optional guarantees low adoption. The middle path is progressive enrollment: allow users in without MFA, but present increasingly firm nudges, and require it for specific actions.
A timeline that works in practice:
- Day 0: Show a banner suggesting MFA enrollment. Dismissable.
- Day 7: Require MFA acknowledgment — show a modal explaining why MFA matters, let them skip once.
- Day 14: Require MFA for sensitive actions (changing email, adding payment methods, exporting data).
- For admin accounts: Require MFA immediately on first login. No grace period.
function getMfaEnrollmentState(user: User): MfaEnrollmentState {
if (user.role === 'admin' && !user.mfaEnabled) {
return { required: true, reason: 'admin_requirement', allowSkip: false };
}
const daysSinceSignup = differenceInDays(new Date(), user.createdAt);
if (!user.mfaEnabled && daysSinceSignup >= 14) {
return {
required: false,
showNudge: true,
nudgeStrength: 'strong',
sensitiveActionsBlocked: true,
};
}
if (!user.mfaEnabled && daysSinceSignup >= 7) {
return {
required: false,
showNudge: true,
nudgeStrength: 'medium',
};
}
return { required: false, showNudge: !user.mfaEnabled && daysSinceSignup >= 1 };
}
Remember-device trust
Asking users for MFA on every login from every device is high friction. "Remember this device for 30 days" is a commonly requested feature that, implemented correctly, maintains reasonable security. The implementation:
- After successful MFA, issue a device trust token — a cryptographically random value stored in an HttpOnly cookie.
- Hash the token and store the hash in your database alongside user_id, device fingerprint, and expiry.
- On future logins from the same device, if the trust token cookie is present and valid, skip the MFA step.
- Invalidate device trust tokens on password change and when the user explicitly removes trusted devices.
async function createDeviceTrustToken(
userId: string,
req: Request,
res: Response,
durationDays = 30
): Promise {
const token = crypto.randomBytes(32).toString('hex');
const tokenHash = crypto.createHash('sha256').update(token).digest('hex');
const expiresAt = new Date(Date.now() + durationDays * 86400 * 1000);
await db.deviceTrust.create({
userId,
tokenHash,
deviceFingerprint: req.headers['user-agent'] ?? '',
ipAddress: req.ip,
expiresAt,
});
res.cookie('device_trust', token, {
httpOnly: true,
secure: true,
sameSite: 'strict',
expires: expiresAt,
path: '/auth',
});
}
Login hint pre-fill
When sending users back to the login page from a deep link or a "session expired" redirect, preserve the email they were last logged in with. This reduces login friction meaningfully — users don't have to remember which email they used for which app.
// Redirect to login while preserving context
function redirectToLogin(req: Request, res: Response, options: {
reason?: string;
returnTo?: string;
} = {}): void {
const params = new URLSearchParams();
// If we know who was logged in, pre-fill their email
const lastEmail = req.cookies['last_login_hint'];
if (lastEmail) {
params.set('login_hint', lastEmail);
}
if (options.returnTo) {
params.set('return_to', options.returnTo);
}
if (options.reason === 'session_expired') {
params.set('message', 'Your session has expired. Please sign in again.');
}
res.redirect(`/login?${params}`);
}
// Set the hint cookie on successful login (not HttpOnly — JS can read for pre-fill)
res.cookie('last_login_hint', user.email, {
secure: true,
sameSite: 'lax',
maxAge: 365 * 86400 * 1000, // 1 year
path: '/',
});
The overarching principle: make the common case (a user who regularly logs in from the same device with the same credentials) as frictionless as possible, while requiring re-verification for anything sensitive. A user who logs in from a known device with a remembered email is a low-risk scenario. A user changing their password, adding a payment method, or accessing admin functions is a high-risk scenario that warrants step-up authentication. Treating all scenarios identically makes the common case unnecessarily painful and may cause users to use weaker passwords to compensate for the friction.