Cycle

The Scriptorium

Smart Assembly code templates and tools for on-chain development in Eve Frontier.

SUI Wallet Authentication
BeginnerChapter 2 of 415 min read

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:

typescript
const 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:

typescript
const 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:

typescript
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,
  }),
});

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
  • typescript
    const 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:

    typescript
    const 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.
    • The sign-in message should be human-readable, include domain binding, and contain a one-time nonce.
    • zkLogin wallets produce different signature formats -- use SDK verification functions that handle both types.
    • Always verify signatures server-side. Client-side verification alone is not secure.

    Sign in to track your progress.