AuthWebPushChannel
WIPWeb Push protocol with VAPID. Deliver magic-link prompts to a user's existing browser tab without email.
Install
bun add web-push
Mint VAPID keys
Run once and persist:
bunx web-push generate-vapid-keys
Output:
Public Key: BAa8WnAwS...
Private Key: 9pUVjK7...
Store both in your .env. The public key also goes to the browser
client (it's safe to expose); the private key stays server-side.
Setup
import { AuthWebPushChannel } from '@gentleduck/auth/channels/webpush'
const channel = new AuthWebPushChannel({
vapidPublicKey: process.env.VAPID_PUBLIC_KEY!,
vapidPrivateKey: process.env.VAPID_PRIVATE_KEY!,
vapidSubject: 'mailto:admin@example.com',
kind: 'webpush',
id: 'webpush',
})
vapidSubject is a contact URL (per the Web Push spec). Use a
mailto: URL or an HTTPS URL pointing at a status page.
Browser subscription
The browser must subscribe before duck-auth can push:
// Service worker (registered separately):
self.addEventListener('push', (event) => {
const data = event.data.json()
event.waitUntil(
self.registration.showNotification(data.title, {
body: data.body,
data: { url: data.url },
}),
)
})
self.addEventListener('notificationclick', (event) => {
event.waitUntil(clients.openWindow(event.notification.data.url))
})
// Client-side:
const registration = await navigator.serviceWorker.register('/sw.js')
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY),
})
// Persist subscription on the server
await fetch('/api/webpush/subscribe', {
method: 'POST',
body: JSON.stringify(subscription),
})
Store the subscription against the identity's profile or a side table:
await auth.identities.update(identityId, {
profile: {
...existingProfile,
webPushSubscriptions: [...existingProfile.webPushSubscriptions, subscription],
},
})
Using as a magic-link channel
import { magicLink } from '@gentleduck/auth/providers/magic-link'
auth.providers.use(
magicLink({
channels: { webpush: channel },
findIdentityByEmail: (email) => auth.identities.findByEmail(email),
}),
)
// Client:
await client.beginProvider('magic-link', { email, channel: 'webpush' })
The channel pushes a notification with a clickable URL; the user taps the notification and lands on your magic-link callback.
This is lower assurance than email - anyone with access to the device can accept the push. Pair with MFA for sensitive accounts.
Subscription expiration
Web Push subscriptions can expire (typically when the user clears
browser data or the browser revokes the key). Handle 410 Gone
responses by removing the stale subscription:
try {
await channel.send({ ... })
} catch (err) {
if (err.code === 410) {
await removeStaleSubscription(identityId, subscription)
}
}
duck-auth's shipped AuthWebPushChannel already removes 410-returning
subscriptions automatically - the manual handling is only needed if
you subclass and override send().