Message Signing for Authentication
Wallet connection only establishes communication with the wallet. To prove that the user owns the wallet address, they must sign a message. This chapter covers the sign-in flow used by the AncientStorage dApp.
The Challenge-Response Flow
``
┌──────────┐ ┌──────────┐ ┌──────────┐
│ Client │ │ Wallet │ │ Server │
│ (dApp) │ │(EVE Vault)│ │ (API) │
└────┬─────┘ └────┬─────┘ └────┬─────┘
│ 1. Request nonce │ │
│─────────────────────────────────────────────────────────►│
│ 2. Return nonce │ │
│◄─────────────────────────────────────────────────────────│
│ 3. Build message │ │
│ 4. signPersonalMessage() │ │
│────────────────────────────►│ │
│ 5. User approves │ │
│◄────────────────────────────│ │
│ 6. Send signature + msg │ │
│─────────────────────────────────────────────────────────►│
│ 7. Verify & create session │ │
│◄─────────────────────────────────────────────────────────│
│ 8. Redirect (authenticated)│ │
`
Step 1-2: Fetch a Nonce
The server generates a one-time nonce to prevent replay attacks:
typescript// Client-side
const nonceResponse = await fetch('/api/auth/sui/nonce');
const { nonce } = await nonceResponse.json();
The nonce is stored server-side in the session and verified when the signature comes back. This ensures each sign-in attempt is unique.
Step 3: Build the Sign-In Message
Construct a human-readable message that includes the nonce and a timestamp:
typescriptconst timestamp = new Date().toISOString();
const message = Sign in to theancients.gg\nNonce: ${nonce}\nTimestamp: ${timestamp};
const messageBytes = new TextEncoder().encode(message);
The message should:
- Identify the site (domain binding)
- Include the nonce (replay protection)
- Include a timestamp (optional, for audit trail)
- Be human-readable (the user sees this in the wallet popup)
Step 4-5: Sign the Message
Use
signPersonalMessage from DAppKit:
typescriptconst dAppKit = useDAppKit();
const signResult = await dAppKit.signPersonalMessage({
message: messageBytes,
});
This triggers the wallet popup. The user reviews the message and clicks "Approve". The wallet returns:
typescript{
bytes: string, // base64 BCS-encoded message that was actually signed
signature: string, // the cryptographic signature
}
The
bytes field is important -- it is not the raw message bytes. SUI wallets wrap the message in a BCS envelope before signing. The server needs both bytes and signature to verify.
Step 6-7: Verify Server-Side
Send the signature, message, and account address to the server:
typescriptconst verifyResponse = await fetch('/api/auth/sui/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
message,
signature: signResult.signature,
signedBytes: signResult.bytes,
address: connection.account.address,
nonce,
}),
});
The server:
Checks that the nonce matches the one it issued
Verifies the cryptographic signature against the address
Creates a session for the authenticated user
Returns user information
typescriptconst verifyData = await verifyResponse.json();
// { success: true, user: { id, walletAddress, playerName } }
zkLogin Considerations
SUI supports zkLogin -- a mechanism where wallets authenticate via OAuth (Google, Apple, etc.) instead of traditional keypairs. zkLogin signatures have different verification requirements:
- Standard signatures: Verified by recovering the public key from the signature and comparing it to the address.
- zkLogin signatures: Include a zero-knowledge proof that must be verified differently. The signature format is larger and contains proof data.
If your dApp supports zkLogin wallets, your server-side verification must handle both signature types. The
@mysten/sui SDK provides verification functions that handle this automatically.
The Complete Sign-In Handler
Here is the full client-side sign-in flow from the AncientStorage dApp:
typescriptconst handleSignIn = useCallback(async () => {
if (!connection.isConnected || !connection.account) return;
setState('signing');
setError(null);
try {
// 1. Get nonce from server
const nonceResponse = await fetch('/api/auth/sui/nonce');
if (!nonceResponse.ok) throw new Error('Failed to fetch nonce');
const { nonce } = await nonceResponse.json();
// 2. Build message
const timestamp = new Date().toISOString();
const message = Sign in to theancients.gg\nNonce: ${nonce}\nTimestamp: ${timestamp};
const messageBytes = new TextEncoder().encode(message);
// 3. Sign with wallet
const signResult = await dAppKit.signPersonalMessage({
message: messageBytes,
});
// 4. Verify with server
const verifyResponse = await fetch('/api/auth/sui/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
message,
signature: signResult.signature,
signedBytes: signResult.bytes,
address: connection.account.address,
nonce,
}),
});
if (!verifyResponse.ok) {
const errorData = await verifyResponse.json();
throw new Error(errorData.error ?? 'Verification failed');
}
const verifyData = await verifyResponse.json();
setPlayerName(verifyData.user.playerName);
setState('authenticated');
router.push(callbackUrl);
router.refresh();
} catch (err) {
setError(err instanceof Error ? err.message : 'Sign-in failed');
setState('error');
}
}, [connection, dAppKit, callbackUrl, router]);
Security Considerations
Nonce Expiry
Nonces should expire after a short window (e.g., 5 minutes). This limits the window for replay attacks if a nonce is intercepted.
Domain Binding
Including the domain in the message (
"Sign in to theancients.gg") prevents signature reuse across different sites. A signature for theancients.gg cannot be used to authenticate on evil-site.com.
HTTPS Only
Message signing should only happen over HTTPS. HTTP would allow man-in-the-middle attacks that could intercept the nonce.
No Private Data in Messages
The signed message is visible to anyone who can see the transaction. Never include sensitive information (passwords, tokens, etc.) in the signed message.
Key Takeaways
- Authentication uses a challenge-response flow: server issues a nonce, wallet signs a message containing the nonce, server verifies the signature.
signPersonalMessage returns bytes (BCS-encoded) and signature` -- both are needed for verification.
Sign in to track your progress.