An attacker who already has a stolen password doesn't need to crack anything else. They just need to send enough push notifications at 2am until someone taps approve to make it stop. That's MFA fatigue: overloading a victim's MFA app with prompts until the fastest way to make it stop is to approve one. It requires the attacker to already have valid credentials, usually from phishing, a breach dump, or a dark web purchase — the push spam isn't the attack, it's what happens after the actual credential theft already succeeded.
No single control stops this
No individual control fully closes this off. MFA fatigue succeeds by exploiting exactly one weak link in the chain, so the fix has to be layered — defense-in-depth, not a single criterion an attacker only has to get past once. Here's what actually stacks up into something real.
Know who has access before anything else
Before any of the controls below mean anything, there needs to be an actual inventory of accounts — not a partial one pulled from whichever system happens to be easiest to query. That inventory needs to be enriched with attribute data (role, device status, employment status) so access decisions can be contextual instead of a flat yes/no, and every entry point into the org needs to be mapped so the account lifecycle process actually covers all of them — not just the ones provisioned through the primary identity provider.
Detecting it before someone taps approve
The pattern itself is simple to catch: a spike in push attempts for a single user in a short window, especially outside normal login hours.
def flag_push_bombing(events: list[dict], window_minutes: int = 5, threshold: int = 4) -> list[str]:
from collections import defaultdict
from datetime import timedelta
by_user = defaultdict(list)
for e in events:
by_user[e["user_id"]].append(e["timestamp"])
flagged = []
for user_id, timestamps in by_user.items():
timestamps.sort()
for i in range(len(timestamps) - threshold + 1):
if timestamps[i + threshold - 1] - timestamps[i] <= timedelta(minutes=window_minutes):
flagged.append(user_id)
break
return flagged
That's the detection half. The actual fix is removing the attack surface, not just alerting on it.
FIDO2 removes the yes/no prompt entirely
Push and OTP both share the same weakness: the user is the last line of defense, and users get tired. FIDO2 doesn't ask a yes/no question that fatigue can wear down — it requires a physical interaction bound to the origin making the request, which a remote attacker sending push spam simply can't trigger. I default new MFA rollouts to FIDO2, hardware key or platform authenticator, and treat push-only as a gap to close, not a baseline to accept.
Device and application context still matter
Restricting sensitive apps to managed devices closes the door on an attacker who does get a credential through and needs a device to use it from. Layering that with per-application access policies — sensitive apps require managed device plus FIDO2, low-risk apps get a lighter bar — means one compromised credential doesn't grant the same access everywhere.
What actually changes user behavior
Generic annual security training doesn't move the needle on this. What does: telling users specifically what a push-bombing attempt looks like, and giving them an unambiguous "report this" path that isn't just tapping deny and hoping it stops. If the only options in the moment are approve or ignore, ignore doesn't stop the attacker from trying again in ten minutes.
How I'd Engineer This Today
Push-bombing a human is a numbers game against attention span. Agents don't have attention span, but they have the same shaped vulnerability: approval fatigue on tool-call consent. An agent chaining a long task can end up requesting the same category of sensitive action repeatedly, and if the approval layer is a simple allow/deny gate with no rate limiting, that's the same exploit pattern wearing a different costume — except now it's a human in the loop getting worn down by agent-generated approval requests instead of an attacker's.
I'd apply the same fix that works for FIDO2: replace the yes/no prompt with a scoped, expiring grant instead of a per-call interruption.
async function requestScopedGrant(agentId: string, actionType: string) {
const existing = await getActiveGrant(agentId, actionType);
if (existing && !isExpired(existing)) return existing;
// one human approval covers this action type for a bounded window,
// instead of one prompt per tool call
return createGrant(agentId, actionType, { ttlMinutes: 15 });
}
One deliberate approval, scoped and time-boxed, beats a stream of low-context prompts every time — for the same reason it works against push bombing. The fix was never "train the human to have more attention." It's removing the exploit's actual dependency on human attention in the first place.