UniAuth
Back to blog
Engineering10 min read

Push Approvals Without a Push Vendor

Push-based two-factor usually means handing a third party a channel into your users' devices. Web Push with VAPID does not require that. We built approve-or-deny as a second factor on the open standard, and the hard parts turned out to be the approval screen and the polling endpoint, not the cryptography.

UniAuth EngineeringMay 26, 2026

Push approval is the best second factor most people will ever use. There is no code to read, so there is no code to read aloud to someone pretending to be support, and nothing to paste into a convincing fake. You get a prompt, you look at it, you approve or you deny.

The usual way to build it is to adopt a vendor SDK and let a third party own the delivery channel to your users' devices. We did not want that, and it turns out you do not need it: Web Push with VAPID is a standard, browsers implement it, and the keys are yours.

VAPID in one paragraph

You generate a keypair. The public half goes to the browser when a user subscribes; the private half signs every message you send to the push service. The push service — operated by the browser vendor — will only accept messages signed by the key that the subscription was created with. It relays ciphertext it cannot read, because the payload is encrypted to a key the browser generated and only the browser holds.

So the delivery network is a dumb pipe by design. It knows a subscription exists and that traffic flows to it. It does not know what the traffic says.

The screen is the security control

Here is the part that gets underestimated. A push prompt that says "Approve sign-in?" is not a second factor. It is a button that a user under pressure will press, and attackers know this — prompt-bombing works precisely because an approval request carries no information to reason about.

The approval screen has to answer the question the user is actually being asked, which is not "do you want to sign in" but "is this you?" So it shows the application, the location, and the IP address, and the guidance is one line long: if you did not start it, deny it.

That is a UI decision doing security work, and it is worth more than any amount of cryptography in the delivery path. A prompt with context is one a user can be wrong about deliberately. A prompt without context is one they cannot be right about at all.

Wiring it as a factor, not a feature

The mistake we were determined to avoid: implementing push on the password login path and calling it done. Social sign-in is a login path. Magic links are a login path. An account with push enrolled and a second-factor policy applied should get the same treatment on every one of them, or the policy is decorative and the weakest path defines the account's real security.

So push is wired into each of them, and the decision of whether a factor is required is extracted into a shared helper rather than re-derived per route. Anything re-derived per route eventually disagrees with itself.

There is also a policy layer: an administrator sets whether push is available, and enrollment is opt-in rather than a cold permission prompt on first sign-in. A browser permission prompt that arrives with no explanation is denied by most people, permanently, and a permanently denied notification permission is very hard to walk back.

The polling endpoint, and a rate limit that looks wrong

While the prompt is outstanding, the page waits. That means polling, which means an endpoint hit repeatedly by a browser that is doing nothing wrong.

Our rate limits are tight by default — five requests per fifteen minutes on password reset, ten on code verification. The push-status endpoint is set to 500, and every time someone reads that table for the first time it looks like a mistake.

"/api/auth/2fa/send-code":   10
"/api/auth/2fa/verify":      10
"/api/auth/2fa/push-status": 500

It is not. A limit is a statement about what legitimate use looks like, not a uniform tax. Legitimate use of this endpoint is a browser polling for up to a couple of minutes while a person finds their phone. Set it to ten and you have not stopped an attacker — who has nothing to gain by polling a status endpoint — you have broken the feature for anyone whose phone is in another room.

The corollary matters more than the number: an endpoint's limit has to be derived from its traffic shape. Copying a neighbouring value because it is nearby is how you end up with a control that only inconveniences real users.

What we would tell anyone building this

  • The standard is enough. You do not need a vendor to own this channel.
  • Spend your time on the approval screen. It is the part that determines whether the factor works.
  • Enumerate your login paths and wire every one. The weakest defines the account.
  • Ask for the notification permission only after the user has asked for the feature.
  • Derive each rate limit from its own traffic shape.

Was this article helpful?

10 min read806 wordsEngineeringUniAuth EngineeringPublished May 26, 2026
Share:Twitter / XLinkedIn