VeztaVezta
Architecture

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:

ChainLibraryVerification
Solanatweetnaclnacl.sign.detached.verify()
EVMethersethers.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 httpOnly cookie named refresh_token
  • Marker cookie -- Non-httpOnly logged_in=1 cookie 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/refresh

The backend reads the refresh_token cookie (or accepts a refreshToken in the request body for mobile clients), validates it, and performs token rotation:

  1. Mark the current refresh token as used
  2. Issue a new access token (15 min)
  3. Issue a new refresh token (7 days) in the same family
  4. 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

PlatformAccess TokenRefresh Token
Web (vezta-fe)In-memory variable (not localStorage)httpOnly cookie set by backend
Mobile (vezta-mobile)In-memory variableExpo 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:

  1. An API call returns 401 Unauthorized
  2. The client attempts to refresh the access token via POST /auth/refresh
  3. If refresh succeeds, the original request is retried with the new token
  4. 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/logout

Requires 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 /terminal if 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);
}

On this page