Auth System
Wallet-based authentication, Google login, Telegram account linking, and JWT lifecycle
Vezta supports two sign-in methods that produce separate accounts:
- Wallet-based -- Solana (
tweetnacl) or EVM (ethers) signature verification - Google OAuth -- backend creates a synthetic Solana wallet for the user
In both cases, the backend issues JWT tokens for subsequent API access. Telegram is a linkable channel on top of an existing account (see below), not a separate sign-in.
Wallet Authentication Flow
Step-by-Step
1. Request Nonce
GET /api/v1/auth/wallet/nonce?address=...&chain=solana|evmBackend generates a random nonce string and returns it. Rate-limited to 20 req/min per IP.
2. Sign Message
The client uses the connected wallet to sign the nonce. No network call -- pure cryptographic operation.
3. Verify Signature
POST /api/v1/auth/wallet
{
"address": "...",
"chain": "solana" | "evm",
"signature": "...",
"nonce": "...",
"referralCode": "..." // optional, first-time only
}Rate-limited to 10 req/min per IP. Verification per chain:
| Chain | Library | Verification |
|---|---|---|
| solana | tweetnacl | nacl.sign.detached.verify() |
| evm | ethers | ethers.verifyMessage() |
4. Token Issuance
The verify endpoint returns:
- Access token (JWT) -- 15-min expiry, returned in body
- Refresh token -- 90-day sliding expiry, set as httpOnly
refresh_tokencookie (each refresh pushesexpiresAtforward) logged_in=1cookie -- non-httpOnly marker for frontend middlewarebeta_access=1cookie -- non-httpOnly beta gate
The signed JWT payload is:
{ sub: string; walletAddress: string; chain: string } // `sub` is the user idThe JwtStrategy maps this to { id, walletAddress, chain }, exposed via @CurrentUser().
Google OAuth Flow
POST /api/v1/auth/google
{ "accessToken": "<google-access-token>" }The backend verifies the Google token, deterministically derives a synthetic Solana wallet for the Google account, and creates / finds the user. Token issuance is identical to wallet sign-in.
A Google account is separate from any wallet account, even if the user later connects the same Solana wallet. Google login is for users who do not (yet) have a self-custody wallet -- they can still receive deposits because the synthetic wallet has a real Solana address. They cannot, however, sign DFlow transactions, so Kalshi-via-DFlow trading is not available on Google accounts.
Telegram Account Linking
Telegram is not a standalone sign-in method — accounts are created via wallet or Google, then optionally linked to a Telegram chat through the trading bot. Linking is handled by the telegram-bot module, not AuthController (User.authMethod is only "wallet" | "google").
Telegram posts updates to POST /api/v1/telegram-bot/webhook (public; validated against a secret_token). The bot's binding service (telegram-binding.service.ts) upserts a TelegramBinding row connecting the Telegram user to the Vezta account.
TelegramBinding Model
| Field | Type | Description |
|---|---|---|
telegramId | BigInt (PK) | Telegram user ID |
userId | UUID | Linked Vezta user (unique) |
chatId | BigInt | Telegram chat ID for DM communication |
walletType | string | connected or created_via_bot |
connectedAt | DateTime? | When the link was established |
lastCommandAt | DateTime? | Last bot interaction |
Token Refresh
When the access token expires, the client calls:
POST /api/v1/auth/refreshBackend reads the refresh_token cookie (or accepts refreshToken in body for non-cookie 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 (90-day sliding expiry) 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.
This endpoint is rate-limited to 60 req/min keyed by token via the @RateLimit decorator (Redis-backed sliding window). The Google sign-in endpoint is limited to 10 req/min, /auth/wallet to 10 req/min, /auth/wallet/nonce to 20 req/min, and /auth/activate-key to 5 req/min.
Client-Side Token Storage
| Platform | Access Token | Refresh Token |
|---|---|---|
| Web (vezta-fe) | In-memory variable (not localStorage) | httpOnly cookie set by backend |
The frontend stores the access token in-memory only -- never in localStorage or sessionStorage. This prevents XSS-based token theft. The logged_in=1 and beta_access=1 cookies are non-httpOnly so the Next.js middleware can read them for route protection, but they contain no sensitive data.
Auto-Refresh and Retry
Both lib/api/client.ts and lib/api/kubb-fetch.ts 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
Both clients use a promise lock (refreshPromise) so that when several API calls fail with 401 simultaneously, only the first triggers a refresh -- all others wait for it. This prevents refresh storms.
Logout
POST /api/v1/auth/logoutRequires a valid access token. The backend invalidates all refresh tokens for the user and clears all three cookies.
Route Protection (Frontend)
proxy.ts is the Next.js middleware (exported as proxy and picked up natively by the framework). It checks both logged_in AND beta_access:
- Protected routes (
/portfolio,/settings,/copy,/rewards) -- Redirect to/login?redirect=...if either cookie is missing - Auth-only routes (no beta required) -- e.g.,
/access-key. Listed inAUTH_NO_BETA_ROUTES. Needlogged_inbut notbeta_access. - Auth routes (
/login,/access-key) -- Redirect to/terminalif both cookies are present - Public routes (
/,/terminal,/markets,/top-traders,/events,/monitor,/ai-signals, etc.) -- Always accessible
proxy.ts also blocks bot user-agents on non-API routes (returns 403).
Global JWT Guard (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()
@Get('wallet/nonce')
async getNonce(@Query('address') address: string, @Query('chain') chain: string) {
return this.authService.getNonce(address, chain);
}The authenticated user is available via @CurrentUser():
@Post('logout')
async logout(@CurrentUser() user: JwtPayload) {
await this.authService.logout(user.id);
}WebSocket Authentication
JWT is passed in the Socket.IO handshake (handshake.auth.token). Public channels (market:*, monitor:*) are open; private channels (user:*) require a valid JWT and ownership verification. The gateway enforces:
- 5000 max total connections
- 10 connections per userId
- 20 connections per IP
- 30 messages/min per client (subscribe + unsubscribe combined)