Auth System
Wallet-based authentication with JWT token lifecycle
Vezta uses wallet-based authentication supporting both Solana and EVM wallets. Users sign a message with their wallet to prove ownership, and the backend issues JWT tokens for subsequent API access.
Authentication Flow
┌────────┐ ┌──────────┐ ┌──────────┐
│ Client │ │ Backend │ │ Database │
└───┬────┘ └────┬─────┘ └────┬─────┘
│ │ │
│ POST /auth/nonce │
│ { walletAddress, chain } │
│──────────────▶│ │
│ │ Store nonce │
│ │───────────────▶│
│ { nonce } │ │
│◀──────────────│ │
│ │ │
│ Sign nonce │ │
│ with wallet │ │
│ │ │
│ POST /auth/verify │
│ { walletAddress, signature, │
│ chain, nonce } │
│──────────────▶│ │
│ │ Verify sig │
│ │ Create/find │
│ │ user │
│ │───────────────▶│
│ │ │
│ { accessToken, user } │
│ + Set-Cookie: refresh_token │
│ + Set-Cookie: logged_in=1 │
│◀──────────────│ │
└───────────────┴────────────────┘Step-by-Step
1. Request Nonce
POST /api/v1/auth/nonce{
"walletAddress": "7xKX...abc",
"chain": "solana"
}The backend generates a random nonce string and returns it. The client will sign this nonce to prove wallet ownership.
2. Sign Message
The client uses the connected wallet to sign the nonce message. No network call is involved -- this is a local cryptographic operation.
3. Verify Signature
POST /api/v1/auth/verify{
"walletAddress": "7xKX...abc",
"chain": "solana",
"signature": "base64-encoded-signature",
"nonce": "the-nonce-from-step-1"
}The backend verifies the signature using the appropriate library:
| Chain | Library | Verification |
|---|---|---|
| Solana | tweetnacl | nacl.sign.detached.verify() |
| EVM | ethers | ethers.verifyMessage() |
On success, the backend creates or finds the user and issues tokens.
4. Token Issuance
The verify endpoint returns:
- Access token (JWT) -- 15-minute expiry, returned in the response body
- Refresh token -- 7-day expiry, set as an
httpOnlycookie namedrefresh_token - Marker cookie -- Non-httpOnly
logged_in=1cookie for frontend middleware route protection
The JWT payload contains:
{ id: string; walletAddress: string; chain: string }Token Refresh
When the access token expires, the client calls:
POST /api/v1/auth/refreshThe backend reads the refresh_token cookie (or accepts a refreshToken in the request body for mobile clients), validates it, and performs token rotation:
- Mark the current refresh token as
used - Issue a new access token (15 min)
- Issue a new refresh token (7 days) in the same
family - Set new cookies
If a previously used refresh token is presented (indicating potential token theft), the entire token family is revoked.
Client-Side Token Storage
| Platform | Access Token | Refresh Token |
|---|---|---|
| Web (vezta-fe) | In-memory variable (not localStorage) | httpOnly cookie set by backend |
| Mobile (vezta-mobile) | In-memory variable | Expo SecureStore |
The frontend stores the access token in-memory only -- never in localStorage or sessionStorage. This prevents XSS-based token theft. The logged_in=1 cookie is non-httpOnly so the Next.js middleware can read it for route protection, but it does not contain any sensitive data.
Auto-Refresh and Retry
Both the web and mobile API clients implement automatic 401 handling:
- An API call returns
401 Unauthorized - The client attempts to refresh the access token via
POST /auth/refresh - If refresh succeeds, the original request is retried with the new token
- If refresh fails, the user is logged out
The web client uses a promise lock (refreshPromise) to prevent multiple simultaneous refresh requests. When several API calls fail with 401 at the same time, only the first triggers a refresh -- all others wait for that single refresh to complete.
Logout
POST /api/v1/auth/logoutRequires a valid access token. The backend invalidates all refresh tokens for the user and clears both the refresh_token and logged_in cookies.
Route Protection
The frontend middleware (middleware.ts) checks the logged_in cookie to determine route access:
- Protected routes (
/portfolio,/settings,/copy,/rewards) -- Redirect to/login?redirect=...if cookie is missing - Auth routes (
/login,/access-key) -- Redirect to/terminalif cookie is present - Public routes (
/,/terminal,/markets,/top-traders) -- Always accessible
Global JWT Guard
On the backend, a global JwtAuthGuard is applied via APP_GUARD. Every route requires a valid JWT by default. To exempt a route, use the @Public() decorator:
@Public()
@Post('nonce')
async getNonce(@Body() dto: NonceRequestDto) {
return this.authService.getNonce(dto);
}The authenticated user is available via @CurrentUser():
@Post('logout')
async logout(@CurrentUser() user: JwtPayload) {
await this.authService.logout(user.id);
}