NEP-413: Signing messages off-chain
NEP-413 off-chain message signing on NEAR: the signMessage payload (message, nonce, recipient), the 2^31+413 tag, and verifying signatures vs EIP-191/712.
Intermediate6 min read3-question check
NEP-413 is a wallet standard for signing an arbitrary message with a NEAR account, without sending a transaction. Its main use is “Sign in with NEAR”: the backend gives the user a challenge, the wallet signs it, and the backend verifies the signature to prove the user controls the account.
The signed bytes are carefully structured so a signature produced for a login can never be replayed as a transaction, for a different app, or a second time.
What gets signed#
| Payload field | Type | Purpose |
|---|---|---|
message | string | Human-readable text the user sees in the wallet. |
nonce | 32 bytes | Random challenge from your backend; prevents replays. Store it and accept it only once. |
recipient | string | Who the signature is for (e.g. your app’s name or domain, or myapp.near). Prevents a signature for one app being used at another. |
callbackUrl | string, optional | Where a redirect-based wallet sends the result. Not needed for extension/injected wallets. |
- Borsh-serialise the tag 2³¹ + 413 = 2147484061 as a
u32, followed by the payload (message,nonce,recipient, optionalcallbackUrl). - Hash those bytes with sha256.
- Sign the hash with a full-access key of the account (ed25519). Wallets must refuse if they hold no full-access key.
- The wallet returns
{ accountId, publicKey, signature }(signature base64-encoded, plusstatefor redirect flows).
Verifying on your backend#
TypeScript
import { createHash } from 'node:crypto'
import nacl from 'tweetnacl'
import bs58 from 'bs58'
const TAG = 2147483648 + 413 // 2^31 + 413
function u32(n: number): Buffer {
const b = Buffer.alloc(4)
b.writeUInt32LE(n)
return b
}
function borshString(s: string): Buffer {
const bytes = Buffer.from(s, 'utf8')
return Buffer.concat([u32(bytes.length), bytes])
}
export function verifyNep413(opts: {
message: string
nonce: Buffer // the exact 32 bytes you issued
recipient: string
callbackUrl?: string
publicKey: string // "ed25519:<base58>"
signature: string // base64
}): boolean {
const payload = Buffer.concat([
u32(TAG),
borshString(opts.message),
opts.nonce, // fixed-size [u8; 32]: no length prefix
borshString(opts.recipient),
opts.callbackUrl === undefined
? Buffer.from([0]) // Option::None
: Buffer.concat([Buffer.from([1]), borshString(opts.callbackUrl)]),
])
const hash = createHash('sha256').update(payload).digest()
const pk = bs58.decode(opts.publicKey.replace('ed25519:', ''))
const sig = Buffer.from(opts.signature, 'base64')
return nacl.sign.detached.verify(hash, sig, pk)
}Check yourself
3 questions · progress saved in this browser