Getting Started

Server Setup

Configure the Better Auth plugin and proof adapter.

Register crossDevice() in your Better Auth plugins list. The plugin creates the order schema and exposes the cross-device endpoints under /cross-device by default.

Quick Start

1. Create a proof adapter

Adapters translate a canonical challenge into the proof format your approving device understands. They also verify the submitted proof.

auth.ts
import { betterAuth } from "better-auth"
import {
  crossDevice,
  CrossDeviceProofError,
  type CrossDeviceAdapter,
} from "@onmax/better-auth-cross-device"

const passkeyAdapter: CrossDeviceAdapter = {
  id: "passkey",
  createChallenge: (challenge) => JSON.stringify(challenge),
  async verifyProof({ challenge, proof }) {
    const verified = await verifyPasskeyProof(challenge, proof)

    if (!verified.ok) {
      throw new CrossDeviceProofError("Passkey proof failed", {
        code: "UNAUTHORIZED",
      })
    }

    return {
      ok: true,
      subject: verified.credentialId,
      userId: verified.userId,
      identity: verified.identity,
    }
  },
}

2. Register the plugin

Pass the adapter to crossDevice(). For login orders, add resolveLogin so an approved proof can become a Better Auth user.

auth.ts
export const auth = betterAuth({
  plugins: [
    crossDevice({
      appName: "Console",
      trustedOrigins: ["https://console.example.com"],
      adapters: [passkeyAdapter],
      resolveLogin: async ({ approvedUserId }) => {
        if (!approvedUserId) return null
        return users.findById(approvedUserId)
      },
    }),
  ],
})
Keep trustedOrigins scoped to the app origins that are allowed to start orders. Origin enforcement is enabled by default.

Adapter contract

Adapters own the proof format. The plugin owns order state, token checks, challenge construction, and endpoint wiring.

HookPurpose
createChallengeConvert the canonical challenge envelope into the adapter message.
verifyProofValidate the submitted proof and return the approved subject.

createChallenge receives the challenge envelope without message plus appName. Return the exact string the approving device should sign, display, or verify.

verifyProof receives the full challenge envelope and the submitted proof. Return ok: true with the verified subject when the proof is valid.

adapter.ts
const adapter: CrossDeviceAdapter = {
  id: "wallet",
  createChallenge: ({ appName, origin, nonce, exp }) => {
    return `${appName} approval for ${origin}\nnonce=${nonce}\nexp=${exp}`
  },
  verifyProof: async ({ challenge, proof }) => {
    const result = await verifyWalletSignature(challenge.message, proof)

    return {
      ok: true,
      subject: result.address,
      identity: { address: result.address, chainId: result.chainId },
    }
  },
}

Login resolution

For login orders, resolveLogin maps a verified proof to a Better Auth user. Return null when the proof is valid but should not create a session.

auth.ts
resolveLogin: async ({ approvedSubject, approvedIdentity }) => {
  return users.findByExternalSubject(approvedSubject, approvedIdentity)
}

For sign and transaction orders, finalization returns the approved proofArtifact instead of creating a session.

Configuration

OptionUse it when
appNameYou want challenges to name the product or console being approved.
endpointPrefixYour Better Auth API needs a different endpoint namespace.
trustedOriginsOnly specific app origins may start orders.
orderTtlSecondsOrders need a shorter or longer approval window.
claimPathTemplateYour approving-device route is not /cross-device/claim/{orderId}?token={claimToken}.
enforceOriginKeep enabled unless the adapter verifies origin inside the proof.

Next Steps

Copyright © 2026