Skip to main content
Use message signing for authentication, verification, or any scenario where you need proof of wallet ownership without a transaction.

The flow

  1. User generates code at actioncode.app
  2. User shares code with your app
  3. Your app creates a message and attaches it
  4. User signs in their wallet
  5. Your app gets the signed message

Authentication example

import { ActionCodesClient } from '@actioncodes/sdk'
import { verify } from '@noble/ed25519'  // or your preferred signature verification

const client = new ActionCodesClient({
  authToken: process.env.ACTION_CODES_TOKEN
})

async function authenticateUser(userCode: string) {
  // 1. Verify the code
  const actionCode = await client.resolve(userCode)
  const walletAddress = actionCode.pubkey

  // 2. Create a unique sign-in message
  const nonce = crypto.randomUUID()
  const timestamp = Date.now()
  const message = [
    'Sign in to MyApp',
    '',
    `Wallet: ${walletAddress}`,
    `Nonce: ${nonce}`,
    `Timestamp: ${timestamp}`
  ].join('\n')

  // 3. Attach the message
  await client.attachMessage(userCode, message, {
    description: 'Sign in to MyApp'
  })

  console.log('Sign-in request sent — waiting for approval...')

  // 4. Wait for signature
  for await (const status of client.observeStatus(userCode)) {
    if (status.signedMessage) {
      // 5. Verify the signature
      const signatureBytes = Buffer.from(status.signedMessage, 'base64')
      const messageBytes = Buffer.from(message)
      const pubkeyBytes = Buffer.from(walletAddress, 'base64') // Adjust encoding as needed

      const isValid = await verify(signatureBytes, messageBytes, pubkeyBytes)

      if (isValid) {
        console.log('Authentication successful!')
        return {
          wallet: walletAddress,
          signedAt: timestamp,
          nonce
        }
      } else {
        throw new Error('Signature verification failed')
      }
    }

    if (status.status === 'expired') {
      throw new Error('Sign-in expired')
    }
  }
}

// Usage
const session = await authenticateUser('48291037')
console.log('Authenticated wallet:', session.wallet)

Simple verification

For simpler cases where you just need proof of ownership:
async function verifyWalletOwnership(userCode: string) {
  const actionCode = await client.resolve(userCode)

  // Simple challenge message
  const challenge = `Verify ownership: ${Date.now()}`

  await client.attachMessage(userCode, challenge)

  for await (const status of client.observeStatus(userCode)) {
    if (status.signedMessage) {
      return {
        verified: true,
        wallet: actionCode.pubkey,
        signature: status.signedMessage
      }
    }
  }

  return { verified: false }
}

Best practices for auth messages

  1. Include a nonce — Prevents replay attacks
  2. Include a timestamp — Allow time-based expiry
  3. Include the wallet address — Confirms which wallet is signing
  4. Use a recognizable format — Users should understand what they’re signing
// Good: Clear, includes security elements
const message = `
Sign in to MyApp

Wallet: ${walletAddress}
Nonce: ${crypto.randomUUID()}
Time: ${new Date().toISOString()}

This signature proves you own this wallet.
`.trim()

// Bad: Unclear, no security elements
const message = 'Login'

Signature verification

The signed message format depends on the wallet. For Solana, you’ll typically verify using ed25519:
import { verify } from '@noble/ed25519'
import { PublicKey } from '@solana/web3.js'

async function verifySignature(
  signedMessage: string,
  originalMessage: string,
  walletAddress: string
): Promise<boolean> {
  const signature = Buffer.from(signedMessage, 'base64')
  const message = Buffer.from(originalMessage)
  const pubkey = new PublicKey(walletAddress).toBytes()

  return await verify(signature, message, pubkey)
}
Different wallets may encode signatures differently. Test with your target wallets (Phantom, Solflare, etc.) to ensure compatibility.