Skip to content

elliotBraem/better-near-auth

better-near-auth

Sign in with NEAR + gasless relay plugin for Better Auth

This Better Auth plugin enables secure authentication via NEAR wallets following NEP-413 and adds a built-in NEP-366 delegate action relayer so authenticated users can call on-chain contracts gaslessly. It uses near-kit for RPC queries and transaction broadcasting, and @hot-labs/near-connect for wallet connection.

Features

  • SIWN authentication — wallet-based sign-in with automatic single-step/two-step flow detection
  • Gasless relay — server relays signed delegate actions on-chain, paying gas from a relayer account
  • Ephemeral relayer keypair — auto-generated ED25519 keypair on first startup, private key encrypted with AES-256-GCM in the database, persists across restarts
  • Profile integration — near-kit profile lookup primary, NEAR Social fallback

Installation

  1. Install the package
npm install better-near-auth
  1. Add the SIWN plugin to your auth configuration:

    import { betterAuth } from "better-auth";
    import { siwn } from "better-near-auth";
    
    export const auth = betterAuth({
        database: drizzleAdapter(db, {
          // db configuration
        }),
        plugins: [
            siwn({
                recipient: "myapp.com",
    
                // Optional: enable gasless relay
                relayer: {
                  whitelistedContracts: ["myapp.near"],
                },
            }),
        ],
    });
  2. Generate the schema to add the necessary fields and tables to the database.

npx @better-auth/cli generate
  1. Add the Client Plugin

    import { createAuthClient } from "better-auth/client";
    import { siwnClient } from "better-near-auth/client";
    
    export const authClient = createAuthClient({
        plugins: [
            siwnClient({
                recipient: "myapp.com",
                networkId: "mainnet",
            })
        ],
    });

Usage

Sign In with NEAR

The signIn.near() method automatically detects wallet capabilities and uses the best available flow:

import { authClient } from "./auth-client";
import { useState } from "react";

export function LoginButton() {
  const { data: session } = authClient.useSession();
  const [isSigningIn, setIsSigningIn] = useState(false);

  if (session) {
    return (
      <div>
        <p>Welcome, {session.user.name}!</p>
        <button onClick={() => authClient.near.disconnect()}>Sign out</button>
      </div>
    );
  }

  const handleSignIn = async () => {
    setIsSigningIn(true);
    await authClient.signIn.near({
      onSuccess: () => {
        setIsSigningIn(false);
      },
      onError: (error) => {
        setIsSigningIn(false);
        console.error("Sign in failed:", error.message);
      },
    });
  };

  return (
    <button onClick={handleSignIn} disabled={isSigningIn}>
      {isSigningIn ? "Signing in..." : "Sign in with NEAR"}
    </button>
  );
}

Supported wallets: HOT Wallet, Meteor Wallet, Intear Wallet, MyNearWallet, and more.

Gasless Relay

Once the relayer is configured on the server, authenticated users can call on-chain contracts without paying gas:

// 1. Build a signed delegate action using the wallet's FAK
import { Gas } from "near-kit";

const signedAction = await authClient.near.buildSignedDelegateAction(
  "myapp.near",
  (builder, receiverId) => builder.functionCall(receiverId, "some_method", { key: "value" }, {
    gas: Gas.Tgas(30),
    attachedDeposit: BigInt(0),
  })
);

// 2. Relay it — the server pays gas
const result = await authClient.near.relayTransaction({
  payload: signedAction,
});

console.log("Tx hash:", result.txHash);

// 3. Check status
const status = await authClient.near.getRelayStatus(result.txHash);

Profile Access

const myProfile = await authClient.near.getProfile();
const aliceProfile = await authClient.near.getProfile("alice.near");

Wallet Management

const accountId = authClient.near.getAccountId();
await authClient.near.disconnect();

Configuration Options

Server Options

Option Type Default Description
recipient string NEP-413 recipient identifier (required)
requireFullAccessKey boolean false Require full access keys
getNonce () => Promise<Uint8Array> Custom nonce generation
getProfile (accountId: string) => Promise<Profile | null> Custom profile lookup
validateLimitedAccessKey (args) => Promise<boolean> Validate FAK when requireFullAccessKey is false
apiKey string process.env.FASTNEAR_API_KEY API key for RPC
rpcUrl string Custom RPC URL (e.g., sandbox, private node)
relayer RelayerConfig Relayer configuration (see below)

Relayer Configuration

Option Type Default Description
accountId string Named relayer account (explicit mode)
privateKey string Base64 private key (explicit mode)
whitelistedContracts string[] Restrict relay to these contracts
maxGasPerTransaction string Max gas per relayed tx
maxDepositPerTransaction string Max deposit per relayed tx

When accountId and privateKey are omitted, the relayer starts in ephemeral mode: an ED25519 keypair is generated on first startup, the implicit account ID is derived from the public key, and the private key is encrypted with AES-256-GCM (using BETTER_AUTH_SECRET as KEK via HKDF-SHA256) and stored in the database. The same keypair is recovered on restart.

Client Options

Option Type Default Description
recipient string NEP-413 recipient (must match server)
networkId "mainnet" | "testnet" "mainnet" NEAR network

Schema

nearAccount

Field Type Description
id string Primary key
userId string → user.id
accountId string NEAR account ID
network string mainnet/testnet
publicKey string Associated public key
isPrimary boolean User's primary account
createdAt date

relayedTransaction

Field Type Description
userId string → user.id
txHash string On-chain tx hash
senderId string Delegate action sender
receiverId string Contract called
status string pending/completed/failed
gasUsed string Gas consumed
createdAt date

relayerKey

Field Type Description
id string Singleton per network
accountId string Implicit NEAR account ID
encryptedPrivateKey string AES-256-GCM encrypted, base64
iv string Initialization vector, base64
publicKey string ed25519:base64 format
network string mainnet/testnet
createdAt date
lastUsedAt date Updated on each relay

API Reference

Client Actions — authClient.near

SIWN

  • nonce(params) — Request a nonce from the server
  • verify(params) — Verify an auth token with the server
  • getProfile(accountId?) — Get user profile (near-kit profile lookup → NEAR Social fallback)
  • getAccountId() — Currently connected account ID
  • getState() — Current wallet state
  • disconnect() — Disconnect wallet and clear cached data
  • link(callbacks?) — Link a NEAR account to the current session
  • unlink(params) — Unlink a NEAR account
  • listAccounts() — List all linked NEAR accounts

Relay

  • buildSignedDelegateAction(receiverId, buildActions) — Build + sign a delegate action via wallet FAK
  • relayTransaction({ payload }) — Submit a signed delegate action to the relayer
  • getRelayStatus(txHash) — Check relayed transaction status
  • getRelayerInfo() — Get relayer account info, mode, and balance
  • relayHistory() — List relayed transactions for current user

authClient.signIn

  • near(callbacks?) — Connect wallet, sign message, and authenticate (single popup)

Callback Interface

interface AuthCallbacks {
  onSuccess?: () => void;
  onError?: (error: Error & { status?: number; code?: string }) => void;
}

Error Codes

Code Description
UNAUTHORIZED_NONCE_REPLAY Nonce already used (replay attack detected)
UNAUTHORIZED Generic auth failure (invalid signature, account mismatch, etc.)

Server Endpoints

Method Path Description
POST /near/nonce Generate nonce for signing
POST /near/verify Verify NEP-413 signature, create session
POST /near/profile Get NEAR profile
POST /near/link-account Link NEAR account to session
POST /near/unlink-account Unlink NEAR account
GET /near/list-accounts List linked NEAR accounts
POST /near/relay Relay a signed delegate action on-chain
GET /near/relay-status/:txHash Check relayed transaction status
GET /near/relayer-info Get relayer accountId, mode, balance
GET /near/relay-history List relayed transactions for current user
POST /near/view Server-side read-only contract call (authenticated)

Advanced Configuration

import { betterAuth } from "better-auth";
import { siwn } from "better-near-auth";
import { generateNonce } from "near-kit";

const usedNonces = new Set<string>();

export const auth = betterAuth({
  plugins: [
    siwn({
      recipient: "myapp.com",
      requireFullAccessKey: false,

      getNonce: async () => generateNonce(),

      getProfile: async (accountId) => {
        try {
          const res = await fetch(`https://api.myapp.com/profiles/${accountId}`);
          if (res.ok) {
            const p = await res.json();
            return { name: p.displayName, description: p.bio, image: { url: p.avatar } };
          }
        } catch {}
        return null;
      },

      validateLimitedAccessKey: async ({ accountId, publicKey, recipient }) => {
        const allowed = ["myapp.near", "social.near"];
        return recipient ? allowed.includes(recipient) : true;
      },

      apiKey: process.env.FASTNEAR_API_KEY,

      relayer: {
        accountId: "relayer.myapp.near",
        privateKey: process.env.RELAYER_PRIVATE_KEY,
        whitelistedContracts: ["myapp.near"],
        maxGasPerTransaction: "300000000000000",
        maxDepositPerTransaction: "0",
      },
    }),
  ],
});

Network Support

The plugin detects the network from the account ID:

  • Accounts ending with .testnet → testnet
  • All other accounts → mainnet

Security

NEP-413 Compliance

  • Proper nonce handling prevents replay attacks
  • Message format and recipient validation
  • 15-minute server-side nonce expiration with DB replay detection

Relayer Key Security

  • Ephemeral private key encrypted at rest with AES-256-GCM
  • KEK derived from BETTER_AUTH_SECRET via HKDF-SHA256
  • Private key held only in process memory — never in env vars or config files
  • Trust model matches Better Auth session tokens: DB access + secret = full access

Access Key Support

  • Full access keys and function-call access keys (FAK)
  • FAK scoped to recipient contract for delegate actions
  • Configurable validation for limited access keys

Troubleshooting

Issue Solution
"Invalid or expired nonce" Server nonces expire after 15 min; check clock sync
"Account ID mismatch" Verify signed message account ID matches wallet
"Network ID mismatch" Ensure networkId matches the account's network
Relay fails with "insufficient balance" Fund the relayer account with NEAR
Relay fails with "contract not whitelisted" Add receiverId to whitelistedContracts

Examples

Browser to Server Example

A full-stack example showing NEAR authentication + gasless relay.

  • Location: examples/browser-2-server/
  • Live Demo: better-near-auth.near.page
  • Tech Stack: Hono, Drizzle ORM, React, TanStack Router
# From repo root
pnpm install
cd examples/browser-2-server
pnpm dev

Development

Interested in contributing? See CONTRIBUTING.md.

Quick start:

pnpm install
pnpm build
pnpm typecheck
pnpm test

Build output:

  • dist/index.js — Server plugin (ESM)
  • dist/client.js — Client plugin (ESM)
  • dist/*.d.ts — TypeScript declarations

Links

About

Sign in with NEAR (SIWN) plugin for Better Auth

Topics

Resources

License

Code of conduct

Contributing

Security policy

Stars

Watchers

Forks

Contributors