Auth

Nimiq cross-device adapter

Verify Nimiq proofs for cross-device login, sign, and transaction orders.

Use @onmax/cross-device-nimiq with @onmax/better-auth-cross-device when a cross-device proof must come from a Nimiq key.

Do not use this adapter by itself as an auth system. The Better Auth cross-device plugin owns order storage, status transitions, sessions, and SSE.

Register the server adapter

server/nimiq-cross-device.ts
import { createNimiqCrossDeviceAdapter } from '@onmax/cross-device-nimiq/server'

export const nimiqAdapter = createNimiqCrossDeviceAdapter({
  id: 'nimiq',
  appName: 'Nimiq Checkout',
})

Expected verification output: login and sign proofs expose publicKeyHex, signatureHex, and optional address. Transaction proofs expose publicKeyHex, address, txHash, and providerResultRaw.

Approve from a phone route

Use the approver inside the route opened by the QR claim URL.

phone/routes/cross-device.ts
import {
  createNimiqBrowserHandoff,
  createNimiqMiniAppApprover,
  getNimiqApprovalEnvironment,
  parseCrossDeviceClaimUrl,
} from '@onmax/cross-device-nimiq'
import { authClient } from '../../app/auth-client'

export async function approveFromCurrentUrl() {
  const environment = getNimiqApprovalEnvironment()
  if (!environment.available)
    return createNimiqBrowserHandoff(window.location.href)

  const claim = parseCrossDeviceClaimUrl(window.location.href)
  const approver = createNimiqMiniAppApprover({
    provider: environment.provider,
    requestAddress: true,
  })

  return approver.approve(authClient.$fetch.bind(authClient), {
    ...claim,
    endpointPrefix: '/cross-device',
  })
}

Expected response:

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

Parse claim URLs

The default claim path is /cross-device/claim/{orderId}?token={claimToken}.

phone/claim.ts
import { parseCrossDeviceClaimUrl } from '@onmax/cross-device-nimiq'

export const claim = parseCrossDeviceClaimUrl(
  'https://pay.example.com/cross-device/claim/order_123?token=claim_456',
)

Expected result:

output
{
  "orderId": "order_123",
  "claimToken": "claim_456"
}

Approve a transaction order

Use resolveTransactionInput only for kind: 'transaction'. The approver signs the challenge, sends sendBasicTransactionWithData(), and submits the transaction artifact to /cross-device/approve.

phone/approve-transaction.ts
import {
  createNimiqMiniAppApprover,
  parseCrossDeviceClaimUrl,
} from '@onmax/cross-device-nimiq'
import { authClient } from '../app/auth-client'

const approver = createNimiqMiniAppApprover({
  resolveTransactionInput: async ({ challenge }) => {
    if (challenge.kind !== 'transaction')
      throw new Error('Unexpected non-transaction challenge')

    return {
      recipient: 'NQ07 0000 0000 0000 0000 0000 0000 0000 0000',
      value: 1299,
      data: `checkout:${challenge.orderId}`,
    }
  },
})

await approver.approve(authClient.$fetch.bind(authClient), {
  ...parseCrossDeviceClaimUrl(window.location.href),
  endpointPrefix: '/cross-device',
})

Expected desktop finalization artifact:

response
{
  "ok": true,
  "status": "finalized",
  "kind": "transaction",
  "proofArtifact": {
    "publicKeyHex": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
    "address": "NQ07 0000 0000 0000 0000 0000 0000 0000 0000",
    "txHash": "0xtransaction",
    "providerResultRaw": "0xtransaction"
  }
}

Build the expected message in tests

Use message builders when tests or previews need the exact signed text.

test/nimiq-message.test.ts
import { buildNimiqCrossDeviceSignMessage } from '@onmax/cross-device-nimiq'

const message = buildNimiqCrossDeviceSignMessage({
  appName: 'Nimiq Checkout',
  origin: 'https://pay.example.com',
  orderId: 'order_123',
  challengeId: 'challenge_123',
  nonce: 'nonce_123',
  issuedAt: '2026-01-01T00:00:00.000Z',
  exp: '2026-01-01T00:02:00.000Z',
  title: 'Approve order order_123',
  summary: 'Sign the checkout payload for EUR 12.99.',
  payloadHash: '7f83b1657ff1fc53b92dc18148a1d65dfa135a13926d73c8042d3a274d49f2fa',
  resource: '/orders/order_123',
})

Available builders:

  • buildNimiqCrossDeviceLoginMessage()
  • buildNimiqCrossDeviceSignMessage()
  • buildNimiqCrossDeviceTransactionMessage()
  • buildNimiqCrossDeviceChallengeMessage()

Normalize proof fields

Use normalizers at trust boundaries before storing or comparing proof fields.

server/proofs.ts
import { normalizeNimiqCrossDeviceProof } from '@onmax/cross-device-nimiq'

const proof = normalizeNimiqCrossDeviceProof({
  publicKeyHex: request.publicKeyHex,
  signatureHex: request.signatureHex,
  address: request.address,
})

These helpers keep phone-side approval code small and server verification deterministic.

Copyright © 2026