Use message signing for authentication, verification, or any scenario where you need proof of wallet ownership without a transaction.
The flow
- User generates code at actioncode.app
- User shares code with your app
- Your app creates a message and attaches it
- User signs in their wallet
- 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
- Include a nonce — Prevents replay attacks
- Include a timestamp — Allow time-based expiry
- Include the wallet address — Confirms which wallet is signing
- 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.