We release security updates for the following versions:
| Version | Supported |
|---|---|
| 1.0.x | β |
The @pokertools/api package uses the following Fastify security plugins:
| Plugin | Purpose |
|---|---|
@fastify/helmet |
Secure HTTP headers (CSP disabled for Swagger UI compatibility) |
@fastify/cors |
Cross-origin resource sharing; in production, origins are restricted to the configured CORS_ORIGIN or denied entirely |
@fastify/jwt |
Signs/verifies JWT access tokens; cookie extraction enabled |
@fastify/cookie |
Parses signed cookies (token cookie via JWT plugin) |
@fastify/rate-limit |
Global 100 req/min per IP (disabled in NODE_ENV=test) |
- Nonce:
POST /auth/noncegenerates a one-time nonce stored in Redis with a 5-minute TTL. - Sign: The client constructs a SIWE message including the nonce and prompts the user's wallet to sign it.
- Login:
POST /auth/loginverifies the signature against the user's Ethereum address (viaviem), burns the nonce in Redis, creates/upserts aUserrow, creates aSessionrow with a uniquejti, and returns a signed JWT. - Session validation: The
authenticatepreHandler hook verifies the JWT and checks that the session'sjtirow is present andrevoked = falsein the database. Revoking a session (session.revoked = true) immediately invalidates the token.
// JWT: signed with JWT_SECRET, extracted from cookie "token"
await app.register(jwt, {
secret: config.JWT_SECRET,
cookie: { cookieName: "token", signed: false },
});
// Cookie parsing: signed with COOKIE_SECRET
await app.register(cookie, {
secret: config.COOKIE_SECRET,
});The JWT payload contains userId, address, and jti (JWT ID for session revocation lookup).
WebSocket connections at /ws/play accept authentication via:
tokencookie attached by the browser during the WebSocket upgrade, orSec-WebSocket-Protocol: jwt.<token>for non-browser clients that cannot rely on cookies.
The server verifies the JWT, checks session validity, and passes a userId into the per-socket table subscription logic.
The engine defaults to a cryptographically secure random number generator powered by Node.js crypto.randomBytes. It uses a fail-closed design: if no secure RNG source is available (e.g., in a non-Node.js runtime without a polyfill), it throws an error rather than falling back to an insecure alternative.
Math.random() is never used as a default. The only way Math.random() could be used is if a user explicitly passes it as a custom randomProvider β which is a documented security anti-pattern.
import { PokerEngine } from "@pokertools/engine";
// β
Secure by default β uses crypto.randomBytes internally
const engine = new PokerEngine({
smallBlind: 10,
bigBlind: 20,
// No randomProvider needed β the default is cryptographically secure
});For testing, simulations, or deterministic replay, you can inject a custom randomProvider. The default secure RNG is only used when randomProvider is omitted.
import { PokerEngine } from "@pokertools/engine";
// For deterministic testing, supply a seeded RNG:
const engine = new PokerEngine({
smallBlind: 10,
bigBlind: 20,
randomProvider: mySeededRng, // () => number
});crypto.randomBytes uses the operating system's cryptographically secure entropy source (/dev/urandom on Linux/macOS, BCryptGenRandom on Windows). This means:
- Unguessable output β Observing previous shuffles reveals nothing about future shuffles.
- No seed extraction β Unlike PRNGs like
Math.random(), the system entropy pool cannot be reconstructed from output. - Fail-closed β If
crypto.randomBytesis unavailable (e.g., restricted runtime), the engine throws a descriptive error rather than silently downgrading to an insecure generator.
Never send full game state to clients. Use engine.view(playerId) to generate a PublicState where opponent hole cards, deck contents, and other hidden information are masked.
// β
Good: masked view
const playerView = engine.view(userId);
socket.send(JSON.stringify(playerView));
// β Bad: exposed full state
socket.send(JSON.stringify(engine.state));The engine uses integer-only arithmetic. All currency values are in cents (1 chip = 1 cent). Floating-point values can introduce rounding errors exploitable for financial gain.
const engine = new PokerEngine({
smallBlind: 500, // $5.00 = 500 cents
bigBlind: 1000, // $10.00 = 1000 cents
});The engine enforces strict chip conservation on every action. The validateIntegrity config option (default true) throws CriticalStateError if:
β(player.stack) + β(pot.amount) + β(currentBets) + rake β constant
Every financial movement in the API generates two offsetting LedgerEntry rows (e.g., a BUY_IN debits MAIN and credits IN_PLAY). The net sum of all ledger entries always balances to zero. The Account.balance field is a cached roll-up maintained atomically within the same database transaction.
await app.register(rateLimit, {
max: 100,
timeWindow: "1 minute",
});The global rate limiter covers all routes. Rate limiting is disabled in NODE_ENV=test. Runtime velocity/risk thresholds are operator-configurable through the documented RISK_* environment variables; these controls are operational abuse limits, not KYC/AML/KYT or identity-screening gates.
The API uses redlock (v5 beta) to serialise access to in-memory table state. Before processing any gameplay action:
- Acquire a Redlock on
lock:table:<tableId>. - Load engine state from Redis.
- Validate and execute the action.
- Save updated state to Redis with optimistic-version Lua scripting.
- Publish
STATE_UPDATEvia Redis Pub/Sub. - Release the lock.
This prevents race conditions when multiple API instances share the same Redis cluster. Retry, jitter, drift, and extension thresholds are configured through the documented REDLOCK_* environment variables.
The Prisma schema defaults to provider = "sqlite" and is used with @prisma/adapter-better-sqlite3. SQLite is suitable for development, testing, and low-volume single-instance deployments. For production with high concurrency or replication requirements, switch the datasource to PostgreSQL and ensure TLS connections.
AdminWallet.xpubβ Encrypted withWALLET_ENCRYPTION_SECRET(a key separate fromJWT_SECRETandCOOKIE_SECRET). Never reuse auth signing secrets for wallet encryption.Session.jtiβ Uniquely identifies a JWT; settingrevoked = trueinvalidates the session without deleting rows.
PaymentTransaction.idempotencyKeyβ Uniquely indexed; prevents duplicate withdrawals from retried API calls.PaymentTransaction.txHashβ Uniqueness enforced per blockchain to prevent deposit replay attacks.
Each user receives a deterministic deposit address derived from a shared AdminWallet.xpub (public-key-only material). The derivation index is atomically incremented to prevent address collisions. Private keys are not required for address derivation β only the admin sweeper needs the corresponding xpriv.
The deposit-monitor BullMQ repeatable job scans ERC-20 Transfer events every 15 seconds. Only transfers meeting these criteria are credited:
toaddress matches an activeDepositSession.- Amount β₯
Token.minDeposit. - Exclude zero-address mint events.
- Require
Blockchain.confirmationsblock confirmations.
Withdrawals require:
- A wallet-signed message containing a unique nonce and a recent timestamp.
- Server-side signature verification against the user's registered Ethereum address.
idempotencyKeyvalidation to prevent duplicate processing.- The withdrawal debit and corresponding
PaymentTransactionrow are created in a single database transaction.
- The Docker image is built from a two-stage
Dockerfile: anode:24-slimbuild stage that compiles TypeScript and generates the Prisma client, and a minimalnode:24-slimproduction stage running as a non-rootpokertoolsuser. - Images are published to
ghcr.io/aaurelions/pokertoolswith tags:latest,major.minor,major, semantic version, and full commit SHA. - Builds use GitHub Actions with
docker/build-push-action@v7, QEMU multi-arch (linux/amd64,linux/arm64), and GitHub Actions cache for layer reuse. - The runtime image is self-contained: it includes the compiled workspace artifacts, Prisma schema tooling needed by the startup entrypoint, and the source files copied during the monorepo build. Do not treat the image as a source-code confidentiality boundary.
- The Docker Compose file exposes dev-only fallback secrets (
JWT_SECRET,COOKIE_SECRET,WALLET_ENCRYPTION_SECRET). Never use these defaults in production.
Do not open a public issue for security vulnerabilities.
Instead email: aurelions@protonmail.com
Include:
- Description of the vulnerability
- Steps to reproduce
- Potential impact
- Suggested fix (if any)
Response time: typically within 48 hours (initial acknowledgment). Fix timelines depend on severity.
The evaluator uses lookup tables with constant-time hand evaluation regardless of hand strength. However, network latency and server load differences between code paths may leak timing information. Mitigations:
- Add random response jitter (0β50 ms).
- Use fixed-time WebSocket frame intervals.
The engine stores deck state and hole cards in process memory. Mitigations:
- Use process isolation and frequent process rotation.
- Restrict
NODE_OPTIONSand debugger ports in production. - Run the API as a non-root user (enforced by the Docker image).
Gameplay actions delivered via WebSocket are not individually nonced/timestamped by the WebSocket protocol itself. Mitigations:
- Redlock serialises action execution; the engine's view-masking semantics prevent out-of-turn actions.
- HTTP-level withdrawal endpoints enforce unique
idempotencyKeyand reject expired timestamps.
Before launching to production:
- Cryptographically secure RNG configured
-
JWT_SECRET,COOKIE_SECRET,WALLET_ENCRYPTION_SECRETset to strong, unique values -
CORS_ORIGINrestricted in production - View masking enabled (default)
- Chip conservation auditing active (default)
- Rate limiting configured appropriately
- HTTPS only (reverse proxy or load balancer with TLS termination)
- PostgreSQL with TLS for production database (not SQLite defaults)
- Redis AOF persistence enabled
- Non-root container user
- Logging enabled (avoid logging sensitive data: JWT payloads, wallet addresses, hole cards)
- Admin secrets supplied through environment variables or secret files, not committed
.envfiles - WebSocket clients authenticate with signed cookies or the
jwt.<token>subprotocol, never URL query strings
This security policy is part of the PokerTools project and follows the same MIT license.
Last Updated: 2026-07-03 Version: 1.0.16