Nimiq 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.
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().
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.
import { authClient } from './auth-client'
export async function signInWithWallet() {
const result = await authClient.signInNimiq({
appName: 'Nimiq Checkout',
})
return result
}
Expected response:
{
"ok": true,
"token": "better-auth-session-token"
}
Pass autoPersistToken: false when your app only wants the Better Auth cookie.
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.
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
{
"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
{
"nonceId": "ZlMuW4xv3hIqnXz_xR7pAA",
"publicKeyHex": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"signatureHex": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
"address": "NQ07 0000 0000 0000 0000 0000 0000 0000 0000"
}
{
"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.
enforceOrigindefaults totrue.- If the client sends an address, the server checks that it derives from the signed public key.
- The default verifier uses portable noble crypto.