Auth

Cross-device approval

Start login, sign, and transaction orders on one device and approve them on another.

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.

server/auth.ts
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

app/auth-client.ts
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.

desktop/start-login.ts
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:

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.

desktop/order-events.ts
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.

phone/approve-login.ts
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:

response
{
  "ok": true,
  "orderId": "HrlSySstW-SR0pbCIY6Rzw",
  "status": "approved"
}

Finalize on desktop

Only the desktop can finalize because only the desktop holds desktopToken.

desktop/finalize-login.ts
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:

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.

desktop/start-sign.ts
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

EndpointMethodHolderPurpose
/cross-device/startPOSTDesktopCreates an order and returns claim data
/cross-device/claimPOSTPhoneExchanges claimToken for challengeToken
/cross-device/challengeGETPhoneReads the challenge envelope
/cross-device/approvePOSTPhoneVerifies the phone proof and marks approved
/cross-device/rejectPOSTPhoneMarks rejected
/cross-device/cancelPOSTDesktopCancels with desktopToken
/cross-device/finalizePOSTDesktopCreates the session or returns a proof artifact
/cross-device/eventsGETDesktopStreams status events over SSE
The claim URL is not a session. Never put desktopToken in the QR code, phone URL, logs, analytics events, or support screenshots.
Copyright © 2026