Security model
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
enforceOriginis 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:
| Token | Holder | Used for |
|---|---|---|
claimToken | Phone claim URL | Claim an unexpired order and receive challengeToken |
challengeToken | Phone approval route | Fetch, approve, or reject the challenge |
desktopToken | Desktop only | Subscribe to events, cancel, or finalize |
Expected trust outputs
Direct sign-in success:
{
"ok": true,
"token": "better-auth-session-token"
}
Cross-device login finalization:
{
"ok": true,
"orderId": "HrlSySstW-SR0pbCIY6Rzw",
"status": "finalized",
"kind": "login",
"redirectTo": "/dashboard",
"token": "better-auth-session-token"
}
Cross-device sign finalization:
{
"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.
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.
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.
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.