Cross-device approval
Use @onmax/better-auth-cross-device when the desktop starts an order and a phone holds the signing key.
Do not use it for same-browser sign-in. Use direct Nimiq Better Auth when window.nimiq is available where the user clicks sign in.
Register the server plugin
Use one adapter per proof type. For Nimiq wallet approval, register the Nimiq adapter.
import { betterAuth } from 'better-auth'
import { crossDevice } from '@onmax/better-auth-cross-device'
import { createNimiqCrossDeviceAdapter } from '@onmax/cross-device-nimiq/server'
export const auth = betterAuth({
plugins: [
crossDevice({
appName: 'Nimiq Checkout',
endpointPrefix: '/cross-device',
trustedOrigins: ['https://pay.example.com'],
orderTtlSeconds: 120,
adapters: [
createNimiqCrossDeviceAdapter({
appName: 'Nimiq Checkout',
}),
],
resolveLogin: async ({ approvedSubject, approvedIdentity, ctx }) => {
const existing = await ctx.context.adapter.findOne({
model: 'user',
where: [{ field: 'publicKey', value: approvedSubject }],
})
if (existing)
return existing
return ctx.context.internalAdapter.createUser({
email: `pk_${approvedSubject}@nimiq.invalid`,
name: '',
image: null,
emailVerified: false,
publicKey: approvedSubject,
address: approvedIdentity?.address,
})
},
}),
],
})
Expected behavior: kind: 'login' creates the desktop Better Auth session during finalization. kind: 'sign' and kind: 'transaction' return a proof artifact instead.
Register the client plugin
import { createAuthClient } from 'better-auth/client'
import { crossDeviceClient } from '@onmax/better-auth-cross-device/client'
export const authClient = createAuthClient({
plugins: [
crossDeviceClient({
endpointPrefix: '/cross-device',
}),
],
})
Start on desktop
Render claimUrl as a QR code or deep link. Keep desktopToken private to the desktop browser.
import { authClient } from '../app/auth-client'
export async function startDesktopLogin() {
const order = await authClient.startCrossDeviceOrder({
kind: 'login',
adapterId: 'nimiq',
returnTo: '/dashboard',
displayTitle: 'Sign in to Nimiq Checkout',
displaySummary: 'Approve this login on your phone.',
})
sessionStorage.setItem(`cross-device:${order.orderId}`, order.desktopToken)
return order
}
Expected response:
{
"orderId": "HrlSySstW-SR0pbCIY6Rzw",
"adapterId": "nimiq",
"kind": "login",
"status": "created",
"claimToken": "U9A_8jW3k8YfF8U6S75QEdcRPpb2nc-A",
"claimUrl": "https://pay.example.com/cross-device/claim/HrlSySstW-SR0pbCIY6Rzw?token=U9A_8jW3k8YfF8U6S75QEdcRPpb2nc-A",
"desktopToken": "xO41niYYujUiWXeh4WLymzQQ4lLoxmWz",
"expiresAt": 1767182520000
}
Listen for status
Use SSE when the desktop should react as soon as the phone claims, approves, rejects, or the order expires.
import { subscribeToCrossDeviceOrder } from '@onmax/better-auth-cross-device/client'
export function subscribeToLogin(orderId: string, desktopToken: string) {
return subscribeToCrossDeviceOrder({
orderId,
desktopToken,
endpointPrefix: '/cross-device',
onEvent(event, payload) {
if (event === 'approved')
window.dispatchEvent(new CustomEvent('cross-device-approved', { detail: payload }))
},
})
}
Events are claimed, waiting_user, approved, rejected, expired, cancelled, and finalized.
Approve on phone
The phone receives orderId and claimToken, exchanges them for a challenge token, signs the challenge, and approves the order.
import {
createNimiqMiniAppApprover,
parseCrossDeviceClaimUrl,
} from '@onmax/cross-device-nimiq'
import { authClient } from '../app/auth-client'
export async function approveCurrentClaim() {
const claim = parseCrossDeviceClaimUrl(window.location.href)
const approver = createNimiqMiniAppApprover()
return approver.approve(authClient.$fetch.bind(authClient), {
...claim,
endpointPrefix: '/cross-device',
})
}
Expected response:
{
"ok": true,
"orderId": "HrlSySstW-SR0pbCIY6Rzw",
"status": "approved"
}
Finalize on desktop
Only the desktop can finalize because only the desktop holds desktopToken.
import { authClient } from '../app/auth-client'
export async function finalizeDesktopLogin(orderId: string) {
const desktopToken = sessionStorage.getItem(`cross-device:${orderId}`)
if (!desktopToken)
throw new Error('Missing desktop token')
const finalized = await authClient.finalizeCrossDeviceOrder({
orderId,
desktopToken,
})
sessionStorage.removeItem(`cross-device:${orderId}`)
return finalized
}
Login finalization response:
{
"ok": true,
"orderId": "HrlSySstW-SR0pbCIY6Rzw",
"status": "finalized",
"kind": "login",
"redirectTo": "/dashboard",
"token": "better-auth-session-token"
}
Start a signature order
Use payloadHash when the phone must approve a specific desktop-side payload.
import { authClient } from '../app/auth-client'
async function sha256Hex(value: string) {
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(value))
return [...new Uint8Array(digest)]
.map(byte => byte.toString(16).padStart(2, '0'))
.join('')
}
const payload = JSON.stringify({
orderId: 'order_123',
amount: 1299,
currency: 'EUR',
})
export const order = await authClient.startCrossDeviceOrder({
kind: 'sign',
adapterId: 'nimiq',
returnTo: '/orders/order_123',
displayTitle: 'Approve order order_123',
displaySummary: 'Sign the checkout payload for EUR 12.99.',
payloadHash: await sha256Hex(payload),
})
Endpoint contract
| Endpoint | Method | Holder | Purpose |
|---|---|---|---|
/cross-device/start | POST | Desktop | Creates an order and returns claim data |
/cross-device/claim | POST | Phone | Exchanges claimToken for challengeToken |
/cross-device/challenge | GET | Phone | Reads the challenge envelope |
/cross-device/approve | POST | Phone | Verifies the phone proof and marks approved |
/cross-device/reject | POST | Phone | Marks rejected |
/cross-device/cancel | POST | Desktop | Cancels with desktopToken |
/cross-device/finalize | POST | Desktop | Creates the session or returns a proof artifact |
/cross-device/events | GET | Desktop | Streams status events over SSE |
desktopToken in the QR code, phone URL, logs, analytics events, or support screenshots.