Auth

Nimiq Better Auth

Add direct Nimiq signed-message sign-in to Better Auth.

Use @onmax/better-auth-nimiq when the same browser can access window.nimiq and should become signed in after one wallet signature.

Do not use it for QR login or phone approval. Use cross-device approval when another device owns the signing key.

Register the server plugin

Use a durable nonce store in production so /nonce and /verify can run on different instances.

server/auth.ts
import { betterAuth } from 'better-auth'
import { bearer } from 'better-auth/plugins'
import { nimiqAuth } from '@onmax/better-auth-nimiq'
import { createKeyValueNimiqNonceStore } from '@onmax/better-auth-nimiq/kv'

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),
})

export const auth = betterAuth({
  trustedOrigins: ['https://pay.example.com'],
  plugins: [
    bearer({ requireSignature: true }),
    nimiqAuth({
      appName: 'Nimiq Checkout',
      endpointPrefix: '/nimiq',
      nonceStore,
      nonceTtlSeconds: 300,
      syncAddressToUser: true,
      syncPublicKeyToUser: true,
    }),
  ],
})

Expected behavior: the plugin adds /nimiq/nonce and /nimiq/verify. After verification, Better Auth creates the session and returns a bearer token for header-based calls.

Register the client plugin

Use the client plugin when your UI should call authClient.signInNimiq().

app/auth-client.ts
import { createAuthClient } from 'better-auth/client'
import {
  getStoredNimiqAuthToken,
  nimiqAuthClient,
} from '@onmax/better-auth-nimiq/client'

export const authClient = createAuthClient({
  plugins: [
    nimiqAuthClient({
      endpointPrefix: '/nimiq',
      tokenStorageKey: 'nimiq_auth_token',
    }),
  ],
  fetchOptions: {
    auth: {
      type: 'Bearer',
      token: () => getStoredNimiqAuthToken() || undefined,
    },
  },
})

Sign in from a button

Call sign-in from a user gesture. The wallet may open approval UI.

app/sign-in.ts
import { authClient } from './auth-client'

export async function signInWithWallet() {
  const result = await authClient.signInNimiq({
    appName: 'Nimiq Checkout',
  })

  return result
}

Expected response:

response
{
  "ok": true,
  "token": "better-auth-session-token"
}

Pass autoPersistToken: false when your app only wants the Better Auth cookie.

app/sign-in-cookie-only.ts
await authClient.signInNimiq({
  appName: 'Nimiq Checkout',
  autoPersistToken: false,
})

Use the helper directly

Use signInWithNimiq() when you already have a Better Auth fetcher and do not want to expose the plugin action.

app/wallet-auth.ts
import { signInWithNimiq } from '@onmax/better-auth-nimiq/client'
import { authClient } from './auth-client'

export async function requireWalletSession() {
  return signInWithNimiq(authClient.$fetch.bind(authClient), {
    appName: 'Nimiq Checkout',
    endpointPrefix: '/nimiq',
    requestAddressBeforeVerify: true,
  })
}

Endpoint contract

POST /nimiq/nonce

response
{
  "action": "auth.login",
  "challengeId": "login",
  "nonceId": "ZlMuW4xv3hIqnXz_xR7pAA",
  "nonce": "m3J1dSpNReD0gUHRJzR0lshdr5EJUH03",
  "origin": "https://pay.example.com",
  "issuedAt": 1767182400000,
  "expiresAt": 1767182700000,
  "message": "Nimiq Checkout requests Nimiq authentication\nAction: auth.login\n..."
}

POST /nimiq/verify

request
{
  "nonceId": "ZlMuW4xv3hIqnXz_xR7pAA",
  "publicKeyHex": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
  "signatureHex": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
  "address": "NQ07 0000 0000 0000 0000 0000 0000 0000 0000"
}
response
{
  "ok": true,
  "token": "better-auth-session-token"
}

Verification rules

  • The nonce has a TTL and is deleted during verify.
  • The signed message includes app name, origin, nonce, challenge id, and expiry.
  • enforceOrigin defaults to true.
  • If the client sends an address, the server checks that it derives from the signed public key.
  • The default verifier uses portable noble crypto.
Copyright © 2026