Near Learn

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 fieldTypePurpose
messagestringHuman-readable text the user sees in the wallet.
nonce32 bytesRandom challenge from your backend; prevents replays. Store it and accept it only once.
recipientstringWho 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.
callbackUrlstring, optionalWhere a redirect-based wallet sends the result. Not needed for extension/injected wallets.
  1. Borsh-serialise the tag 2³¹ + 413 = 2147484061 as a u32, followed by the payload (message, nonce, recipient, optional callbackUrl).
  2. Hash those bytes with sha256.
  3. Sign the hash with a full-access key of the account (ed25519). Wallets must refuse if they hold no full-access key.
  4. The wallet returns { accountId, publicKey, signature } (signature base64-encoded, plus state for redirect flows).

Verifying on your backend#

Verify a NEP-413 signature (Node / TypeScript sketch)
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

  1. 1.A backend checks that a NEP-413 signature verifies against the returned publicKey. What critical check is still missing?
  2. 2.What is the purpose of the recipient field?
  3. 3.Why is the payload prefixed with the tag 2³¹ + 413?