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.
- 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
- Install the package
npm install better-near-auth-
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"], }, }), ], });
-
Generate the schema to add the necessary fields and tables to the database.
npx @better-auth/cli generate-
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", }) ], });
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.
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);const myProfile = await authClient.near.getProfile();
const aliceProfile = await authClient.near.getProfile("alice.near");const accountId = authClient.near.getAccountId();
await authClient.near.disconnect();| 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) |
| 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.
| Option | Type | Default | Description |
|---|---|---|---|
recipient |
string |
— | NEP-413 recipient (must match server) |
networkId |
"mainnet" | "testnet" |
"mainnet" |
NEAR network |
| 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 |
| 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 |
| 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 |
SIWN
nonce(params)— Request a nonce from the serververify(params)— Verify an auth token with the servergetProfile(accountId?)— Get user profile (near-kit profile lookup → NEAR Social fallback)getAccountId()— Currently connected account IDgetState()— Current wallet statedisconnect()— Disconnect wallet and clear cached datalink(callbacks?)— Link a NEAR account to the current sessionunlink(params)— Unlink a NEAR accountlistAccounts()— List all linked NEAR accounts
Relay
buildSignedDelegateAction(receiverId, buildActions)— Build + sign a delegate action via wallet FAKrelayTransaction({ payload })— Submit a signed delegate action to the relayergetRelayStatus(txHash)— Check relayed transaction statusgetRelayerInfo()— Get relayer account info, mode, and balancerelayHistory()— List relayed transactions for current user
near(callbacks?)— Connect wallet, sign message, and authenticate (single popup)
interface AuthCallbacks {
onSuccess?: () => void;
onError?: (error: Error & { status?: number; code?: string }) => void;
}| Code | Description |
|---|---|
UNAUTHORIZED_NONCE_REPLAY |
Nonce already used (replay attack detected) |
UNAUTHORIZED |
Generic auth failure (invalid signature, account mismatch, etc.) |
| 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) |
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",
},
}),
],
});The plugin detects the network from the account ID:
- Accounts ending with
.testnet→ testnet - All other accounts → mainnet
- Proper nonce handling prevents replay attacks
- Message format and recipient validation
- 15-minute server-side nonce expiration with DB replay detection
- Ephemeral private key encrypted at rest with AES-256-GCM
- KEK derived from
BETTER_AUTH_SECRETvia 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
- Full access keys and function-call access keys (FAK)
- FAK scoped to recipient contract for delegate actions
- Configurable validation for limited access keys
| 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 |
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 devInterested in contributing? See CONTRIBUTING.md.
Quick start:
pnpm install
pnpm build
pnpm typecheck
pnpm testBuild output:
dist/index.js— Server plugin (ESM)dist/client.js— Client plugin (ESM)dist/*.d.ts— TypeScript declarations