Push notification MFA is the UX improvement that made MFA adoption go from "the IT department forces it" to "users prefer it." Instead of opening an authenticator app, reading a 6-digit code, and typing it before it rotates, the user gets a push notification on their phone: "Approve login?" They tap approve. Done in 3 seconds. The challenge is that basic push approval — without additional context — is vulnerable to MFA fatigue attacks, where an attacker who has the password spams the user with push requests until they approve one out of frustration or confusion. Number matching is the mitigation.
Basic push MFA flow
The server-side flow for push MFA involves long-polling or WebSocket on the auth server while the mobile app receives the push notification through APNs or FCM.
// Step 1: Initiate push challenge
async function initiatePushChallenge(userId, loginContext) {
const challengeId = crypto.randomUUID();
const displayNumber = Math.floor(Math.random() * 90) + 10; // 2-digit: 10-99
await redis.setex(`push_challenge:${challengeId}`, 120, JSON.stringify({
userId,
displayNumber,
status: 'pending',
createdAt: Date.now(),
ip: loginContext.ip,
userAgent: loginContext.userAgent,
location: loginContext.location, // derived from IP geolocation
}));
// Send push notification via FCM/APNs
const deviceToken = await getUserPushToken(userId);
await sendPushNotification(deviceToken, {
title: 'Login request',
body: `New login from ${loginContext.location}`,
data: {
challengeId,
displayNumber: displayNumber.toString(),
ip: loginContext.ip,
},
});
return { challengeId, displayNumber }; // both returned to browser
}
// Step 2: Browser polls for approval
async function pollChallengeStatus(challengeId) {
const data = await redis.get(`push_challenge:${challengeId}`);
if (!data) return { status: 'expired' };
return JSON.parse(data);
}
// Step 3: Mobile app responds
async function respondToChallenge(challengeId, response, deviceToken) {
const data = await redis.get(`push_challenge:${challengeId}`);
if (!data) throw new Error('Challenge expired');
const challenge = JSON.parse(data);
// Verify the device token matches the enrolled device
const enrolledToken = await getUserPushToken(challenge.userId);
if (deviceToken !== enrolledToken) throw new Error('Unknown device');
await redis.setex(`push_challenge:${challengeId}`, 30, JSON.stringify({
...challenge,
status: response === 'approve' ? 'approved' : 'denied',
respondedAt: Date.now(),
}));
}
Number matching for phishing resistance
Without number matching, a push notification just says "approve login?" — the user does not know which login request corresponds to which notification. An attacker performing an MFA fatigue attack sends push after push; a confused or tired user may approve one.
Number matching shows a 2-digit number on the login screen. The push notification on the mobile app shows multiple numbers and asks the user to select the one shown on their screen. The user can only approve the correct request by reading the number on the screen they are actively trying to log in on. An attacker's push notification will show a different number, and the user must actively enter the wrong number to approve it.
// Mobile app: number matching challenge
// The push notification payload contains the challengeId
// The app fetches the challenge details and presents number choices
// Push notification data on mobile:
{
"challengeId": "abc-123",
"displayNumber": "47", // shown as one choice among several
"context": "Login from San Francisco",
"choices": ["23", "47", "81"] // randomized, one is correct
}
// App flow: user sees "Which number is shown on your screen?"
// [23] [47] [81]
// They tap 47 (the number shown on the browser)
// App sends: { challengeId, selectedNumber: "47", response: "approve" }
// Server validation
async function validateNumberMatchResponse(challengeId, selectedNumber, deviceToken) {
const data = await redis.get(`push_challenge:${challengeId}`);
if (!data) throw new Error('Challenge expired');
const challenge = JSON.parse(data);
if (selectedNumber !== challenge.displayNumber.toString()) {
// User entered wrong number — either fat-fingered or this is the attacker's push
await recordSecurityEvent(challenge.userId, 'push_mfa_wrong_number', {
challengeId,
selectedNumber,
});
throw new Error('Incorrect number selected');
}
// Correct number — approve
await respondToChallenge(challengeId, 'approve', deviceToken);
}
Comparing push to TOTP and hardware keys
Each MFA method has a different security and usability profile:
- TOTP (Google Authenticator, Authy): phishing-resistant only if the user notices the domain is wrong before entering the code. A convincing phishing site can relay TOTP codes in real time. Low UX friction for technical users. No infrastructure required beyond the TOTP algorithm.
- Push MFA without number matching: vulnerable to MFA fatigue. Better UX than TOTP. Requires push infrastructure (FCM/APNs) and a mobile app.
- Push MFA with number matching: resistant to MFA fatigue and real-time phishing relay. Best UX of non-passkey options. Requires mobile app.
- Hardware keys (FIDO2/WebAuthn): fully phishing-resistant (bound to the origin domain). Slightly higher friction (requires physical token or platform authenticator). No infrastructure beyond WebAuthn registration/assertion APIs.
- Passkeys: phishing-resistant, excellent UX, no hardware required. Newest option; adoption is growing but older users may find enrollment confusing.
Fallback strategy
Push MFA fails when the user's phone is unavailable — dead battery, no signal, lost phone. Your fallback stack matters:
// MFA method priority order (per user, can be configured)
const MFA_PRIORITY = ['passkey', 'push', 'totp', 'backup_codes'];
async function getMFAChallengeOptions(userId) {
const methods = await db.query(
`SELECT method, is_primary FROM mfa_credentials
WHERE user_id = $1 AND revoked_at IS NULL
ORDER BY is_primary DESC`,
[userId]
);
// Always offer at least one fallback beyond the primary method
return methods.rows.map(m => ({
method: m.method,
label: MFA_METHOD_LABELS[m.method],
isPrimary: m.is_primary,
}));
}
// Backup codes: a set of one-time codes for account recovery
// Each code is SHA-256 hashed before storage
async function verifyBackupCode(userId, code) {
const codeHash = createHash('sha256').update(code.trim().toLowerCase()).digest('hex');
const result = await db.query(`
UPDATE backup_codes
SET used_at = NOW()
WHERE user_id = $1
AND code_hash = $2
AND used_at IS NULL
RETURNING id
`, [userId, codeHash]);
if (!result.rowCount) throw new Error('Invalid or already used backup code');
// Alert user that a backup code was used
await sendSecurityAlert(userId, 'backup_code_used');
return true;
}
Never require users to enroll push MFA without offering an alternative enrollment option. Users in countries with unreliable push delivery (due to network conditions or app store access restrictions), users who do not have smartphones, and users who want to use a security key should all have viable paths to complete MFA enrollment without push notifications.