Auth

Security model

Understand nonce, origin, proof, and session boundaries.

Use this page to decide where trust changes hands in direct Nimiq sign-in and cross-device approval.

Do not treat a signed wallet message as product authorization by itself. These packages prove wallet control and return Better Auth artifacts; your app still decides what the user may do.

Direct sign-in boundary

The server trusts a login only after it verifies a server-issued nonce challenge.

sequenceDiagram
  participant Browser
  participant Auth
  participant Wallet

  Browser->>Auth: POST /nimiq/nonce
  Auth-->>Browser: nonceId, message, expiresAt
  Browser->>Wallet: sign(message)
  Wallet-->>Browser: publicKeyHex, signatureHex
  Browser->>Auth: POST /nimiq/verify
  Auth-->>Browser: session cookie, token

Verification checks:

  • The nonce exists and has not expired.
  • The verify request origin matches the nonce origin when enforceOrigin is enabled.
  • The nonce is deleted during verification.
  • The signature verifies against the signed message and public key.
  • The optional address derives from the public key.
  • Better Auth creates the session only after verification succeeds.

Cross-device boundary

Cross-device approval separates phone approval from desktop finalization.

sequenceDiagram
  participant Desktop
  participant Auth
  participant Phone

  Desktop->>Auth: POST /cross-device/start
  Auth-->>Desktop: orderId, claimUrl, desktopToken
  Phone->>Auth: POST /cross-device/claim
  Auth-->>Phone: challengeToken
  Phone->>Auth: GET /cross-device/challenge
  Auth-->>Phone: message
  Phone->>Auth: POST /cross-device/approve
  Desktop->>Auth: POST /cross-device/finalize
  Auth-->>Desktop: session token or proof artifact

Token responsibilities:

TokenHolderUsed for
claimTokenPhone claim URLClaim an unexpired order and receive challengeToken
challengeTokenPhone approval routeFetch, approve, or reject the challenge
desktopTokenDesktop onlySubscribe to events, cancel, or finalize

Expected trust outputs

Direct sign-in success:

direct-sign-in
{
  "ok": true,
  "token": "better-auth-session-token"
}

Cross-device login finalization:

cross-device-login
{
  "ok": true,
  "orderId": "HrlSySstW-SR0pbCIY6Rzw",
  "status": "finalized",
  "kind": "login",
  "redirectTo": "/dashboard",
  "token": "better-auth-session-token"
}

Cross-device sign finalization:

cross-device-sign
{
  "ok": true,
  "orderId": "7cEcn_LJ8q3Y0CCO6Pr8jw",
  "status": "finalized",
  "kind": "sign",
  "proofArtifact": {
    "publicKeyHex": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
    "signatureHex": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
    "address": "NQ07 0000 0000 0000 0000 0000 0000 0000 0000"
  }
}

Origin policy

Keep origin checks enabled unless you are writing a controlled local test.

server/auth.ts
nimiqAuth({
  appName: 'Nimiq Checkout',
  enforceOrigin: true,
})

crossDevice({
  appName: 'Nimiq Checkout',
  trustedOrigins: ['https://pay.example.com'],
  enforceOrigin: true,
  adapters: [nimiqAdapter],
  resolveLogin,
})

Do not set enforceOrigin: false to work around proxy headers in production. Fix forwarded origin, host, and x-forwarded-proto handling instead.

Storage policy

Use durable storage for production auth state.

server/nonce-store.ts
import { createKeyValueNimiqNonceStore } from '@onmax/better-auth-nimiq/kv'

export const nonceStore = createKeyValueNimiqNonceStore({
  get: key => env.AUTH_KV.get(key),
  set: (key, value, options) => env.AUTH_KV.put(key, value, {
    expirationTtl: options.ttlSeconds,
  }),
  delete: key => env.AUTH_KV.delete(key),
})

Direct sign-in needs nonce records to survive across the runtime instance that issued /nonce and the runtime instance that receives /verify.

Cross-device approval stores orders, token hashes, challenge token hashes, status, identity, and proof artifacts in the Better Auth adapter model named crossDeviceOrder by default.

Product authorization

After auth succeeds, use your own app policy for product actions.

server/orders.ts
export async function releaseOrder(user: { id: string, publicKey?: string }, orderId: string) {
  const order = await findOrder(orderId)
  if (!order || order.ownerPublicKey !== user.publicKey)
    throw new Error('Order is not owned by the signed-in wallet')

  return markOrderReadyForSettlement(order.id)
}

These packages do not handle wallet recovery, payment compliance, fiat or crypto settlement, chargebacks, or payout policy.

Copyright © 2026