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:
typescriptconst 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.
typescriptfunction 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:
typescriptconst 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:
typescriptfunction 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:
typescriptasync 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:
typescriptconst 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:
typescriptfunction 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:
typescriptclass 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.
Sign in to track your progress.