Most second-factor flows put the user to work: open a different app, find the code, type it before it expires. Push authentication inverts that. The user clicks "Sign In" on the web, their phone buzzes, they tap "Approve", and that's it. Nothing to copy, nothing to intercept.
This guide builds that flow end-to-end using Authsignal: a React web app, a React Native mobile app, and a Node.js server coordinating between them.
How it works
Three actors coordinate the push flow. The web app initiates sign-in requests, the server orchestrates challenges through Authsignal, and the mobile app presents them to the user for approval.
The flow:
- User clicks "Sign In" on the web app.
- The server calls Authsignal's
trackAPI to create a challenge for thesignInaction. - The web app uses the returned token to create a push challenge via Authsignal's browser SDK.
- The mobile app detects the pending challenge and shows an approval screen.
- The user taps "Approve" or "Deny".
- The server updates the action state in Authsignal, and the web app receives the result.
Project structure
authsignal-push-auth/
├── apps/
│ ├── api/ # Express server
│ ├── web/ # React + Vite
│ └── mobile/ # React Native + Expo
└── .env
Prerequisites
- Authsignal account: get your tenant ID, API secret, and API URL from Settings > API Keys
- Expo CLI (
npm install -g expo-cli) - iOS Simulator (Xcode) or Android Emulator
Step 1: Server - Track the sign-in action
When the web app wants to authenticate a user, it calls the server. The server tells Authsignal to track the action, which creates a challenge that the mobile device will respond to.
//apps/api/src/index.ts:
import { Authsignal } from "@authsignal/node";
const authsignal = new Authsignal({
apiSecretKey: process.env.AUTHSIGNAL_SECRET_KEY,
apiUrl: process.env.AUTHSIGNAL_API_URL
});
app.post("/api/auth/push/start", async (req, res) => {
const { userId, action } = req.body;
const tracked = await authsignal.track({
userId,
action, // "signIn"
attributes: {
custom: { note: "Web sign-in confirmation" }
}
});
if (!tracked.isEnrolled) {
return res.json({ error: "User has no enrolled device" });
}
// Store challenge context so mobile can discover it
createChallengeContext({
idempotencyKey: tracked.idempotencyKey,
userId,
action
});
return res.json({
token: tracked.token,
idempotencyKey: tracked.idempotencyKey,
isEnrolled: tracked.isEnrolled
});
});
The isEnrolled check matters here. If the user hasn't registered a device yet, there's nowhere to send the push. The server returns early and the web app can fall back to another method. Device enrollment is covered in Step 4.
Step 2: Web - Create the push challenge
The web app takes the token from the server and uses Authsignal's browser SDK to create the actual push challenge. After that, it polls the server for the result.
import { Authsignal } from "@authsignal/browser";
const authsignal = new Authsignal({
tenantId: config.tenantId,
baseUrl: config.baseUrl
});
async function sendSignInPush() {
// Tell the server to track the action
const startResponse = await fetch("/api/auth/push/start", {
method: "POST",
body: JSON.stringify({ userId: "user_123", action: "signIn" })
});
const { token, idempotencyKey } = await startResponse.json();
// Create the push challenge via Authsignal
authsignal.setToken(token);
const challenge = await authsignal.push.challenge({ action: "signIn" });
// Register the challengeId with the server
await fetch("/api/auth/push/challenge-created", {
method: "POST",
body: JSON.stringify({
idempotencyKey,
challengeId: challenge.data.challengeId
})
});
// Poll for the result
while (true) {
const status = await fetch(`/api/auth/push/status/${idempotencyKey}`);
const { status: result } = await status.json();
if (result === "approved") break;
if (result === "denied") break;
await sleep(2000);
}
}
push.challenge() is what delivers the challenge to the mobile device. The server's track call creates the action, but the browser SDK triggers the push. Polling at 2-second intervals is fine for a demo. In production, use WebSockets or configure APNs/FCM through the Authsignal portal for real-time delivery.
Step 3: Mobile - Detect and approve the challenge
The mobile app polls the server for pending challenges. When one arrives, it surfaces an approval screen.
//apps/mobile/src/App.tsx
import { Authsignal } from "react-native-authsignal";
useEffect(() => {
const poll = async () => {
const response = await fetch(
`/api/mobile/pending-challenge?userId=${userId}`
);
const { challenge } = await response.json();
if (challenge) {
setCurrentChallenge(challenge);
}
};
const interval = setInterval(poll, 3000);
return () => clearInterval(interval);
}, [userId]);
When the user taps "Approve", the mobile app tells the server, which updates the action state in Authsignal:
// apps/api/src/index.ts
app.post("/api/mobile/resolve-challenge", async (req, res) => {
const { idempotencyKey, approved } = req.body;
const context = getChallengeContext(idempotencyKey);
await authsignal.updateAction({
userId: context.userId,
action: context.action,
idempotencyKey,
attributes: {
state: approved
? UserActionState.CHALLENGE_SUCCEEDED
: UserActionState.CHALLENGE_FAILED
}
});
resolveChallengeByIdempotencyKey(
idempotencyKey,
approved ? "approved" : "denied"
);
return res.json({ ok: true });
});
Once updateAction is called with CHALLENGE_SUCCEEDED, the web app's polling picks up the result and grants access. The round trip typically completes in 3-5 seconds.
Step 4: Device enrollment
Before a user can approve push challenges, their device needs a credential. This generates a cryptographic key pair: the private key stays in the device's secure enclave, the public key is stored by Authsignal.
The server creates a scoped enrollment token:
app.post("/api/auth/enroll/start", async (req, res) => {
const tracked = await authsignal.track({
userId: req.body.userId,
action: "enrollPushCredential",
attributes: { scope: "add:authenticators" }
});
return res.json({ token: tracked.token });
});
The mobile app uses it to register the device:
const enrollment = await fetch("/api/auth/enroll/start", {
method: "POST",
body: JSON.stringify({ userId })
});
const { token } = await enrollment.json();
await authsignal.push.addCredential({
token,
requireUserAuthentication: false
});
The scope: "add:authenticators" restricts this token to credential registration only, it can't be used to approve actions. requireUserAuthentication: false means the private key can be used without biometric verification. Set this to true for higher-security contexts (banking, enterprise), this adds a Face ID or Touch ID prompt every time the key is used to approve a challenge, not just during enrollment.
Deploy and test
git clone https://github.com/authsignal-examples/authsignal-push-auth
npm install
cp .env.example .env
Add your Authsignal credentials to .env:
AUTHSIGNAL_TENANT_ID=your_tenant_id
AUTHSIGNAL_SECRET_KEY=your_secret_key
AUTHSIGNAL_API_URL=https://api.authsignal.com/v1
Start the applications:
npm run dev -w api # Express server on port 4000
npm run dev -w web # Vite dev server on port 5173
cd apps/mobile && npx expo run:ios
Open the web app, enter a user ID, and tap "Enroll Device" on the mobile app first. After that, clicking "Sign In" on the web will surface an approval screen on the phone. Approve it, and the web app shows the result.
The full source code is available on GitHub.
What's next
This covers simple approve/deny push. In the next post, we’ll cover number matching: a short code shown on the web that the user must confirm on their phone before the approval goes through. This prevents push fatigue attacks, where an attacker repeatedly sends approval requests hoping the user taps "Approve" by mistake. It's the vector used in the Uber breach in 2022. Number matching is also required for financial transaction authorization under FCA/SCA.
.png)


