Security

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:

  1. HttpOnly cookie (access_token) — preferred, XSS-safe
  2. 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 boundIpAddress is 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

  1. 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 as data.csrfToken
  2. Client stores the token value and sends it as the X-CSRF-Token header on every state-changing request
  3. The csrfProtection middleware 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:

HeaderEffect
Content-Security-PolicyRestricts resource loading
X-Frame-Options: SAMEORIGINPrevents clickjacking from other origins
X-Content-Type-Options: nosniffPrevents MIME sniffing
Strict-Transport-SecurityEnforces HTTPS
X-XSS-Protection: 0Disables the legacy browser XSS filter
Referrer-PolicyControls referrer leakage

Permission System

The engine uses a hierarchical permission level system:

LevelRoleDescription
aSuper AdminFull system access
a1AdminAdministrative functions
bModeratorUser management and support
cAgent ManagerAgent oversight
dAgentPlayer management
eVIP PlayerSpecial privileges
fRegular PlayerBasic 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: requestPasswordReset returns 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 (not password)
  • [ ] BCRYPT_ROUNDS: 12 or higher in production
  • [ ] HTTPS enforced (via nginx reverse proxy + Helmet HSTS)
  • [ ] Redis enabled for distributed session management at scale
Try the live demo Pricing

Have questions about Poker Engine? We reply fast.