| name | siwn |
| description | Set up the SIWN server plugin for Better Auth, configure NEP-413 authentication with recipient and API key, handle nonce generation, signature verification, account linking and unlinking, and NEAR profile lookup. Load when adding NEAR wallet sign-in to a Better Auth server, configuring siwn() plugin options, or debugging NEP-413 verify or nonce issues.
|
| metadata | {"type":"core","library":"better-near-auth","library_version":"1.7.3"} |
| sources | ["elliotBraem/better-near-auth:src/index.ts","elliotBraem/better-near-auth:src/profile.ts","elliotBraem/better-near-auth:src/types.ts","elliotBraem/better-near-auth:src/schema.ts","elliotBraem/better-near-auth:README.md","elliotBraem/better-near-auth:LLM.txt"] |
Better-Near-Auth — SIWN Authentication
Better Auth plugin for NEAR wallet authentication following NEP-413. Provides server-side nonce generation, signature verification, account linking, and profile lookup.
Setup
import { betterAuth } from "better-auth";
import { siwn } from "better-near-auth";
export const auth = betterAuth({
database: drizzleAdapter(db, {
}),
plugins: [
siwn({
recipient: "myapp.com",
apiKey: process.env.FASTNEAR_API_KEY,
}),
],
});
After adding the plugin, generate the database schema:
npx @better-auth/cli generate
This creates three tables: nearAccount, relayedTransaction, and relayerKey.
Core Patterns
Configure with custom RPC URL
For sandbox or private nodes, override the default RPC endpoint:
siwn({
recipient: "myapp.com",
rpcUrl: "http://localhost:3030",
apiKey: process.env.FASTNEAR_API_KEY,
});
Custom profile lookup
Override the default FastNear KV → NEAR Social fallback chain:
siwn({
recipient: "myapp.com",
getProfile: async (accountId) => {
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 } };
}
return null;
},
});
Validate function-call access keys
By default the plugin validates that the signing key is either a full-access key or a function-call key scoped to the recipient. Override with validateLimitedAccessKey:
siwn({
recipient: "myapp.com",
validateLimitedAccessKey: async ({ accountId, publicKey, recipient }) => {
const allowed = ["myapp.near", "social.near"];
return recipient ? allowed.includes(recipient) : true;
},
});
Link and unlink NEAR accounts
After authentication, link additional NEAR accounts to the same session:
await authClient.near.link({
onSuccess: () => console.log("linked"),
onError: (err) => console.error(err),
});
await authClient.near.unlink({ accountId: "alice.near" });
You cannot unlink the last authentication method — link another account first.
Sub-account creation
Create named sub-accounts (e.g. myapp.parent.near). See the subaccount skill for the full reference.
Basic setup:
siwn({
recipient: "myapp.com",
relayer: {
accountId: "relayer.myapp.near",
privateKey: process.env.RELAYER_PRIVATE_KEY,
},
subAccount: {
parentAccount: "myapp.near",
},
});
If the parent account differs from the relayer account, provide the parent key via secrets:
siwn({
recipient: "myapp.com",
relayer: {
accountId: "relayer.myapp.near",
privateKey: process.env.RELAYER_PRIVATE_KEY,
},
secrets: {
parentKey: process.env.PARENT_KEY,
},
subAccount: {
parentAccount: "user.parent.near",
minDeposit: "0.5 NEAR",
},
});
Client-side flow:
const { data } = await authClient.near.checkSubAccountAvailability({
subAccountName: "myapp",
});
if (!data.available) {
console.log("Not available:", data.reason);
return;
}
const result = await authClient.near.createSubAccount({
subAccountName: "myapp",
publicKey: "ed25519:...",
});
console.log(result.data.accountId);
To use parent ownership, contract deployment, init calls, transaction hooks, or lifecycle callbacks with automatic rollback, see the subaccount skill.
Server Endpoints
| Method | Path | Description |
|---|
| POST | /near/nonce | Generate hex-encoded nonce for signing |
| POST | /near/verify | Verify NEP-413 signature, create session |
| POST | /near/profile | Get NEAR profile (requires session) |
| 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/set-primary-account | Set primary linked NEAR account |
| POST | /near/create-sub-account | Create a sub-account under a parent account |
| POST | /near/check-sub-account-availability | Check if a sub-account name is available |
Plugin Options
| Option | Type | Default | Description |
|---|
recipient | string | — | NEP-413 recipient identifier (required) |
requireFullAccessKey | boolean | false | Require full access keys |
getNonce | () => Promise<Uint8Array> | generateNonce() | Custom nonce generation |
getProfile | (accountId) => Promise<Profile | null> | FastNear KV → NEAR Social | Custom profile lookup |
validateLimitedAccessKey | (args) => Promise<boolean> | Default FAK validation | Validate limited access keys |
apiKey | string | process.env.FASTNEAR_API_KEY | API key for RPC |
rpcUrl | string | — | Custom RPC URL |
relayer | RelayerConfig | — | See relay skill |
recipients | DualNetworkConfig<string> | — | Per-network recipient (overrides recipient for mainnet/testnet) |
subAccount | SubAccountConfig | DualNetworkConfig<SubAccountConfig> | — | Sub-account configuration for delegated account creation |
Email Handling
.near accounts: efiz.near → efiz@near.email (automatic)
- Non-
.near accounts (e.g. implicit hex accounts): no email set; users add email via other Better Auth plugins
Common Mistakes
CRITICAL Recipient mismatch between server and client
Wrong:
siwn({ recipient: "myapp.near" });
siwnClient({ recipient: "myapp.com" });
Correct:
siwn({ recipient: "myapp.com" });
siwnClient({ recipient: "myapp.com" });
The client signs a message with the recipient embedded. The server verifies against its own recipient. A mismatch causes verification to fail with no clear error.
Source: src/index.ts:225, src/client.ts:17
See also: client/SKILL.md — client recipient must match server recipient
HIGH Sending raw nonce bytes instead of hex-encoded string
Wrong:
const nonce = new Uint8Array(32);
await authClient.near.verify({
signedMessage,
message,
recipient,
nonce: nonce,
accountId,
});
Correct:
const nonceBytes = generateNonce();
const nonceHex = hex.encode(nonceBytes);
await authClient.near.verify({
signedMessage,
message,
recipient,
nonce: nonceHex,
accountId,
});
The verify endpoint hex-decodes the nonce string. Sending raw bytes or a non-hex string causes decode failure or nonce replay detection failure.
Source: src/types.ts:53-58, src/client.ts:106-109
HIGH Forgetting to generate DB schema after adding plugin
Wrong:
export const auth = betterAuth({
plugins: [siwn({ recipient: "myapp.com" })],
});
Correct:
export const auth = betterAuth({
plugins: [siwn({ recipient: "myapp.com" })],
});
The plugin adds nearAccount, relayedTransaction, and relayerKey tables. Without running the CLI, the database will be missing these tables and all endpoints will fail at runtime with adapter errors.
Source: README.md:56-60
MEDIUM Network mismatch from account ID suffix
Wrong:
await authClient.near.nonce({
accountId: "alice.near",
networkId: "testnet",
});
Correct:
await authClient.near.nonce({
accountId: "alice.near",
networkId: "mainnet",
});
Network is auto-detected from the accountId: .testnet → testnet, otherwise → mainnet. The nonce endpoint validates that networkId matches the account's detected network and rejects mismatches.
Source: src/profile.ts:6-8, src/index.ts:546-552
See also: client/SKILL.md — client siwnClient networkId should match the account's network
HIGH Not configuring parentAccount for sub-account creation with ephemeral relayer
Wrong:
siwn({
recipient: "myapp.com",
relayer: {},
subAccount: {},
});
Correct:
siwn({
recipient: "myapp.com",
relayer: {
accountId: "relayer.myapp.near",
privateKey: process.env.RELAYER_PRIVATE_KEY,
},
subAccount: {
parentAccount: "myapp.near",
},
});
Ephemeral mode generates an implicit hex account (e.g. 7a3c4b5c...) which cannot own sub-accounts — NEAR only allows named accounts to create sub-accounts. Set subAccount.parentAccount to a named account, or use an explicit relayer with a named account and omit parentAccount (defaults to relayer accountId).
Source: src/index.ts:1395-1405, src/index.ts:290-304
MEDIUM Missing secrets.parentKey when parent account differs from relayer
Wrong:
siwn({
recipient: "myapp.com",
relayer: {
accountId: "relayer.myapp.near",
privateKey: process.env.RELAYER_PRIVATE_KEY,
},
subAccount: {
parentAccount: "user.parent.near",
},
});
Correct:
siwn({
recipient: "myapp.com",
relayer: {
accountId: "relayer.myapp.near",
privateKey: process.env.RELAYER_PRIVATE_KEY,
},
secrets: {
parentKey: process.env.PARENT_KEY,
},
subAccount: {
parentAccount: "user.parent.near",
minDeposit: "0.5 NEAR",
},
});
The creation transaction must be signed by the parent account. If parentAccount differs from the relayer account, provide secrets.parentKey so the server can sign as the parent.
Source: src/index.ts:1403-1415, src/index.ts:1420-1423
See also: subaccount skill