This document describes the security architecture of the poker engine — authentication flows, CSRF protection, rate limiting, input validation, encryption, and game integrity mechanisms.
JWT Authentication Flow
The engine uses short-lived access tokens paired with long-lived refresh tokens, implementing token rotation and session tracking.
Token Lifecycle
POST /api/v1/auth/login
└─> verifies credentials + rate limit
└─> creates Session record (DB)
└─> generateTokens(user, { sessionId })
├─ access token (JWT, 5m default, signed with JWT_ACCESS_SECRET)
└─ refresh token (JWT, 1d default, signed with JWT_REFRESH_SECRET)
└─> sets HttpOnly cookies: access_token, refresh_token
└─> returns user profile and expiresIn in response body
(native clients sending X-Auth-Client: mobile also receive the tokens in the body)
Token contents (access token payload):
interface TokenPayload {
sub: string; // user ID
username: string;
email: string;
permissions: string; // e.g. "f" (player), "b" (mod), "a1" (admin), "a" (super admin)
groupId: string;
sessionId?: string; // DB session for revocation
}
Token Rotation (Refresh)
POST /api/v1/auth/refresh
└─> reads refresh token from refreshToken body, then HttpOnly cookie
└─> rotateRefreshToken(token)
├─ rejects unknown and expired tokens
├─ rejects revoked tokens and deletes the whole family (reuse detected)
└─ marks the old token as revoked
└─> issues a new pair in the same family
└─> sets rotated HttpOnly cookies: access_token, refresh_token
└─> returns expiresIn in response body
Family IDs allow detecting refresh token reuse. If a previously revoked token is presented, the entire token family is deleted and the request fails.
Token Extraction Order
The authenticate middleware checks in this order:
- HttpOnly cookie (
access_token) — preferred, XSS-safe Authorization: Bearer <token>header — for backwards compatibility and API clients
Source: src/api/middleware/auth.ts
Session Validation
Every authenticated request validates the session ID embedded in the access token:
// src/api/middleware/auth.ts (simplified)
if (payload.sessionId) {
const sessionValid = await isSessionValid(payload.sessionId);
if (!sessionValid) throw new AuthenticationError('Session has been revoked');
await updateSessionActivity(payload.sessionId);
}
Sessions can be revoked individually (e.g., on logout, password change) or in bulk (e.g., on password reset — all refresh tokens are deleted).
Two-Factor Authentication (2FA)
When a user has TOTP enabled, login returns a temporary token instead of full auth tokens:
POST /api/v1/auth/login (2FA user)
└─> credentials valid
└─> generateTwoFactorTempToken(userId) — short-lived, signed with JWT_2FA_TEMP_SECRET
└─> returns { requires2FA: true, tempToken }
POST /api/v1/auth/verify-2fa
└─> verifies TOTP code or backup code ({ tempToken, token } or { tempToken, backupCode })
└─> sets HttpOnly cookies: access_token, refresh_token
└─> returns user profile and expiresIn in response body
Telegram Mini App Authentication
The optional Telegram auth plugin delegates credential issuance to src/services/telegram-auth.service.ts. That service verifies Telegram Mini App initData with src/services/telegram/init-data-verifier.ts, applies freshness and replay checks, refuses admin-group users, and issues the same session-backed JWT/cookie tokens as password login. Auto-provisioned Telegram users are created with passwordSetAt = null, so they cannot unlink Telegram until they set a normal password.
All password-backed account creation paths, including src/services/auth.service.ts, admin user provisioning, developer provisioning, and src/services/agent/player/agent-player.service.ts, set passwordSetAt when a usable password is created or reset.
Plugin routes live under /api/v1/plugins/telegram-auth/*; configuration, account management, and operational behavior are documented in Telegram Mini App Authentication.
IP Binding
Optional per-user IP binding enforced on every authenticated request:
- Stored in
User.boundIpAddress(explicit, must be configured by admin) - Grace period (
User.ipBindingGracePeriodMinutes) allows temporary IP changes after login - If binding is enabled but
boundIpAddressis not set, access is allowed with a warning (prevents accidental lockout)
Source: src/api/middleware/ip-binding.middleware.ts
CSRF Protection
CSRF protection uses the Double Submit Cookie pattern via the csrf-csrf library.
How It Works
- Authenticated clients fetch
GET /api/v1/auth/csrf-token— server generates a random token, sets it as an HttpOnly cookie (__Host-psifi.x-csrf-token), and returns the token asdata.csrfToken - Client stores the token value and sends it as the
X-CSRF-Tokenheader on every state-changing request - The
csrfProtectionmiddleware validates that the header matches the cookie value
// src/api/middleware/csrf.ts
doubleCsrf({
getSecret: () => env.CSRF_SECRET,
getSessionIdentifier: (req) => req.user?.sub || req.ip || 'anonymous',
cookieName: '__Host-psifi.x-csrf-token',
cookieOptions: { sameSite: 'strict', path: '/', secure: true, httpOnly: true },
size: 64,
ignoredMethods: ['GET', 'HEAD', 'OPTIONS'],
});
The __Host- cookie prefix enforces that the cookie is only sent to the exact host (no subdomains) and requires Secure and path=/.
Scope
csrfProtection is applied route by route to state-changing endpoints across the cashier, account, agent, tournament, ticket and admin routes. Two kinds of request skip validation: webhook endpoints, which use signature verification instead, and requests authenticated through the Authorization header rather than a cookie:
// src/api/middleware/csrf.ts
if (req.path.startsWith('/api/v1/webhooks/')) {
return next(); // Webhooks use signature verification
}
if (req.user?.sub && req.authSource === 'header') {
return next(); // Not cookie-authenticated
}
A failed check returns 403 with the code INVALID_CSRF_TOKEN.
Rate Limiting
Rate limiting is implemented at two levels:
Login Rate Limiting
Database-backed per-username and per-IP rate limiting in src/services/admin-auth.service.ts. Survives IP rotation (username-based) and username rotation (IP-based). Failed attempts are recorded and cleared on successful login.
API Rate Limiting
Express-level rate limiting using express-rate-limit. Limiters are defined in src/config/rate-limit.ts (for example loginRateLimiter, registrationRateLimiter, refreshRateLimiter, twoFactorRateLimiter, adminReadRateLimiter, adminWriteRateLimiter) and attached to individual routes.
Middleware Ordering
Rate limiters must run after any middleware that decides whether a request is eligible to spend the budget:
- Authenticated, user-keyed limiters run after
authenticate, so the limiter keys on the user ID rather than collapsing every anonymous caller onto one IP bucket. - Unauthenticated limiters on validated endpoints run after
validateBody, so a payload rejected by the schema never consumes the caller’s budget.
POST /auth/register is the reference example. Its limiter allows 3 requests per IP
per hour, and the route is wired as:
router.post(
'/register',
validateBody(registerSchema), // schema rejects → 400, budget untouched
registrationRateLimiter, // only well-formed attempts count
requireFeatureToggle('registration'),
handler
);
With the limiter first, three typo’d submissions (for example a username containing a
character USERNAME_PATTERN disallows) would exhaust the hourly budget and every later
attempt — including correct ones — would return 429 Too many registration attempts,
masking the real validation error for an hour.
Both invariants are enforced by src/api/audit/authenticated-rate-limiter-order.test.ts,
which asserts middleware order directly against the route sources.
Input Validation with Zod
All API inputs are validated using Zod schemas via dedicated middleware factories:
// src/api/middleware/validation.ts
// Validate request body
router.post('/endpoint', validateBody(mySchema), handler);
// Validate query parameters (stored in req.validatedQuery)
router.get('/endpoint', validateQuery(querySchema), handler);
// Validate URL parameters
router.get('/endpoint/:id', validateParams(paramsSchema), handler);
Validation errors are converted to structured ValidationError responses:
{
"success": false,
"error": {
"code": "VALIDATION_ERROR",
"message": "Validation failed",
"details": {
"email": ["Invalid email format"],
"amount": ["Number must be greater than 0"]
}
}
}
Shared schemas are defined in src/models/schemas.ts; some route files define route-specific schemas alongside their handlers.
Encryption at Rest
Sensitive data (TOTP secrets, API keys, payment provider credentials) is encrypted using AES-256-GCM via the ENCRYPTION_KEY environment variable.
ENCRYPTION_KEY: 64 hex characters (32 bytes)
Generate: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
ENCRYPTION_KEY is required in production — the environment schema enforces this:
// src/config/env-schema.ts
if (data.NODE_ENV === 'production' && !data.ENCRYPTION_KEY) {
ctx.addIssue({ message: 'ENCRYPTION_KEY is required in production' });
}
Previous keys can be listed in ENCRYPTION_PREVIOUS_KEYS for rotation. Tokens (password reset, email verification, refresh tokens) are stored as SHA-256 hashes. High-entropy random strings do not need bcrypt — hashing is sufficient and much faster.
Security Headers (Helmet)
The server uses helmet to set protective HTTP headers. Applied globally in src/app.ts, with custom CSP directives and HSTS (one year, includeSubDomains, preload). Key headers set by Helmet:
| Header | Effect |
|---|---|
Content-Security-Policy | Restricts resource loading |
X-Frame-Options: SAMEORIGIN | Prevents clickjacking from other origins |
X-Content-Type-Options: nosniff | Prevents MIME sniffing |
Strict-Transport-Security | Enforces HTTPS |
X-XSS-Protection: 0 | Disables the legacy browser XSS filter |
Referrer-Policy | Controls referrer leakage |
Permission System
The engine uses a hierarchical permission level system:
| Level | Role | Description |
|---|---|---|
a | Super Admin | Full system access |
a1 | Admin | Administrative functions |
b | Moderator | User management and support |
c | Agent Manager | Agent oversight |
d | Agent | Player management |
e | VIP Player | Special privileges |
f | Regular Player | Basic access |
Permission checks use middleware chaining:
// Route protection
router.delete('/users/:id',
authenticate, // verify JWT + session
requireSuperAdmin, // level 'a'
requireFeature('users:delete'), // feature-specific override check
handler
);
Feature-level overrides allow granting or denying specific admin features per user group, independent of the hierarchy level. Source: src/api/middleware/auth.ts
Game Integrity
All game state is managed server-side. Clients only send actions (fold, call, raise, etc.) — they never modify state directly.
Key integrity measures:
- Server-side deck management: Cards are shuffled and dealt server-side; clients never see undealt cards
- Action validation: Every player action is validated against the current game state before being applied
- Bet enforcement: Bet amounts are validated against pot limits, betting caps, and player stacks on the server
- Showdown evaluation: Hand evaluation runs server-side using the
HandEvaluatorFactory— clients receive evaluated results, not raw cards for client-side evaluation - WebSocket authentication: WebSocket connections with a token must present a valid JWT; connections without one are rate-limited per IP and treated as guests with lobby-only access
Password Security
- Passwords are hashed with bcrypt (
BCRYPT_ROUNDS, default 12; set to 4 in CI for speed) - Password reset tokens:
crypto.randomBytes(32)→ stored as SHA-256 hash, 1-hour expiry - On password reset, all refresh tokens are revoked (force re-login everywhere)
- Email enumeration is prevented:
requestPasswordResetreturns silently for unknown emails
Production Checklist
- [ ]
JWT_ACCESS_SECRET,JWT_REFRESH_SECRET,JWT_2FA_TEMP_SECRET: minimum 64 chars, randomly generated - [ ]
CSRF_SECRET: minimum 32 chars, randomly generated - [ ]
ENCRYPTION_KEY: exactly 64 hex chars (32 bytes) - [ ]
CORS_ORIGINS: no localhost, explicit production domains only - [ ]
DATABASE_URL: strong unique password (notpassword) - [ ]
BCRYPT_ROUNDS: 12 or higher in production - [ ] HTTPS enforced (via nginx reverse proxy + Helmet HSTS)
- [ ] Redis enabled for distributed session management at scale