Cycle

The Scriptorium

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

SUI Wallet Authentication
BeginnerChapter 3 of 420 min read

Session Management

Once a user has signed in via wallet signature, you need to manage their session. This chapter covers session persistence, re-authentication, disconnection handling, and the pitfalls of wallet-based sessions.

Session Lifecycle

``

Connect Wallet → Sign Message → Session Created → ... → Session Expires → Re-authenticate

├── User disconnects wallet → Session Invalidated

├── MaxEpoch expires (zkLogin) → Re-authenticate

└── Page reload → Check existing session

`

Server-Side Sessions

After verifying the wallet signature, the server creates a session:

typescript
// Server-side (API route)
export async function POST(request: Request) {
  const { message, signature, signedBytes, address, nonce } = await request.json();

  // 1. Verify nonce
  const storedNonce = await getStoredNonce(nonce);
  if (!storedNonce) {
    return Response.json({ error: 'Invalid nonce' }, { status: 401 });
  }

  // 2. Verify signature
  const isValid = await verifySuiSignature(address, signature, signedBytes);
  if (!isValid) {
    return Response.json({ error: 'Invalid signature' }, { status: 401 });
  }

  // 3. Create or update user
  const user = await findOrCreateUser(address);

  // 4. Create session
  await createSession(user.id, {
    walletAddress: address,
    expiresAt: Date.now() + SESSION_DURATION_MS,
  });

  return Response.json({
    success: true,
    user: { id: user.id, walletAddress: address, playerName: user.playerName },
  });
}

Handling Page Reloads

When the user reloads the page, the dApp needs to restore their session. The autoConnect: true flag in createDAppKit handles wallet reconnection:

typescript
const kit = createDAppKit({
  // ...
  autoConnect: true,  // Reconnects to the last-used wallet
});

But wallet reconnection and session validation are different things:

  • Wallet reconnects: DAppKit remembers the last wallet and reconnects automatically.
  • Session check: Your app must verify that the server-side session is still valid.
  • typescript
    function useAuthState() {
      const connection = useWalletConnection();
      const [sessionValid, setSessionValid] = useState<boolean | null>(null);
    
      useEffect(() => {
        if (!connection.isConnected) {
          setSessionValid(false);
          return;
        }
    
        // Check if the server-side session is still valid
        fetch('/api/auth/session')
          .then(res => res.json())
          .then(data => setSessionValid(data.authenticated))
          .catch(() => setSessionValid(false));
      }, [connection.isConnected]);
    
      return { isConnected: connection.isConnected, sessionValid };
    }

    Handling Disconnection

    When the user disconnects their wallet, the dApp should invalidate the session:

    typescript
    const handleDisconnect = useCallback(async () => {
      try {
        // 1. Disconnect the wallet
        await dAppKit.disconnectWallet();
    
        // 2. Invalidate the server-side session
        await fetch('/api/auth/logout', { method: 'POST' });
    
        // 3. Reset UI state
        setState('idle');
        setError(null);
      } catch {
        // Disconnect failure is non-critical
      }
    }, [dAppKit]);

    The user can also disconnect directly from the wallet extension (without going through your dApp). To handle this, watch for connection state changes:

    typescript
    function useDisconnectWatcher() {
      const connection = useWalletConnection();
      const prevConnected = useRef(connection.isConnected);
    
      useEffect(() => {
        if (prevConnected.current && !connection.isConnected) {
          // Wallet was disconnected externally
          fetch('/api/auth/logout', { method: 'POST' });
        }
        prevConnected.current = connection.isConnected;
      }, [connection.isConnected]);
    }

    MaxEpoch Expiry (zkLogin)

    zkLogin wallets use ephemeral keypairs that expire at a specific SUI epoch (the MaxEpoch). When the epoch advances past MaxEpoch, the wallet's ephemeral key becomes invalid and the user must re-authenticate.

    This manifests as transaction failures with errors like:

    `

    Signature expired: current epoch X > max epoch Y

    `

    To handle this gracefully:

    typescript
    async function executeTransaction(tx: Transaction) {
      try {
        return await signAndExecute({ transaction: tx });
      } catch (err) {
        const msg = err instanceof Error ? err.message : '';
    
        if (msg.includes('Signature expired') || msg.includes('max epoch')) {
          // zkLogin key expired — prompt re-authentication
          setError('Your wallet session has expired. Please sign in again.');
          setState('idle');
          await dAppKit.disconnectWallet();
          return null;
        }
    
        throw err;
      }
    }

    Session Duration Strategy

    Wallet-based sessions have different considerations than traditional username/password sessions:

    Short Sessions (hours)

    • More secure (less time for stolen cookies to be exploited)
    • More disruptive (user must re-sign frequently)
    • Appropriate for high-value operations

    Long Sessions (days/weeks)

    • Better UX (less signing friction)
    • Higher risk if session token is compromised
    • Appropriate for read-heavy dApps

    Sliding Sessions

    A middle ground: the session expires after a period of inactivity, not after a fixed duration:

    typescript
    const SESSION_IDLE_TIMEOUT = 4  60  60 * 1000; // 4 hours of inactivity
    
    // On each API request, extend the session
    async function extendSession(sessionId: string) {
      await updateSession(sessionId, {
        expiresAt: Date.now() + SESSION_IDLE_TIMEOUT,
      });
    }

    Persisting State Across Reloads

    For client-side state that should survive page reloads, use localStorage:

    typescript
    function usePersistedState<T>(key: string, defaultValue: T) {
      const [value, setValue] = useState<T>(() => {
        if (typeof window === 'undefined') return defaultValue;
        const stored = localStorage.getItem(key);
        return stored ? JSON.parse(stored) : defaultValue;
      });
    
      useEffect(() => {
        localStorage.setItem(key, JSON.stringify(value));
      }, [key, value]);
    
      return [value, setValue] as const;
    }
    
    // Usage
    const [lastSsuId, setLastSsuId] = usePersistedState('lastSsuId', '');

    Be careful not to store sensitive data (session tokens, private keys) in localStorage. Only store non-sensitive preferences like the last-used SSU ID.

    Error Boundaries for Wallet Hooks

    Wallet hooks can throw in unexpected situations (extension uninstalled, browser permissions revoked, etc.). Wrap wallet-dependent components in an error boundary:

    typescript
    class SuiErrorBoundary extends Component<
      { children: React.ReactNode },
      { hasError: boolean }
    > {
      static getDerivedStateFromError() {
        return { hasError: true };
      }
    
      render() {
        if (this.state.hasError) {
          return this.props.children; // Degrade gracefully
        }
        return this.props.children;
      }
    }
    
    // Usage
    <SuiErrorBoundary>
      <DAppKitProvider dAppKit={kit}>
        {children}
      </DAppKitProvider>
    </SuiErrorBoundary>

    The AncientStorage dApp wraps the entire DAppKitProvider in this boundary, ensuring that wallet failures do not crash the entire application.

    Key Takeaways

    • Wallet connection (autoConnect) and session validity are separate concerns -- check both on page load.
    • Invalidate server-side sessions when the wallet is disconnected, whether through your UI or directly from the wallet extension.
    • zkLogin wallets have ephemeral keys that expire at MaxEpoch -- detect Signature expired` errors and prompt re-authentication.
    • Use sliding sessions for a balance between security and UX.
    • Do not store sensitive data in localStorage -- use it only for non-sensitive preferences.
    • Wrap wallet providers in error boundaries to prevent crashes from propagating.

    Sign in to track your progress.