This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
Security
Threat model
The backend is a small optional account service. It protects:
- Account credentials and sessions.
- E-mail addresses and verification state.
- Leaderboard scores.
- Chess match integrity.
- Operator access to the management panel.
It does not receive gameplay state, saved games, device identifiers or analytics.
Password storage
Passwords are hashed with bcrypt after a SHA-256 pre-hash:
bcrypt-sha256$<bcrypt hash of base64(sha256(password))>
Why:
- bcrypt only consumes the first 72 bytes; pre-hashing lets the full password contribute.
- SHA-256 output is base64-encoded to avoid NUL bytes in the bcrypt input.
- The
bcrypt-sha256$prefix records the scheme. - Legacy bcrypt hashes still verify and are upgraded after the next successful sign-in.
Passwords must be 8–128 characters. They are never logged or returned.
Sessions
- HS256 JWTs signed with
JWT_SECRET(minimum 32 characters). - Issuer
universal-gaming, audienceuniversal-gaming-app. - Algorithm is pinned to
HS256during verification. - Tokens carry
sub,username,verandexp. auth_versionis checked against the database on every authenticated request.- A password reset bumps
auth_version, invalidating all older tokens. - Deleting an account invalidates its tokens because the account no longer exists.
- Default lifetime is 30 days;
JWT_TTL_SECONDScontrols it.
Rotating JWT_SECRET signs everyone out immediately.
E-mail tokens
- Verification and reset tokens are 32 random bytes encoded as base64url.
- Only the SHA-256 hash is stored in the database.
- Verification links expire after 48 hours.
- Password-reset links expire after 30 minutes by default.
- Reset tokens are single-use and cleared when the password changes.
- Changing an e-mail clears pending verification and reset state.
Account enumeration
- Login failures always return the same message.
POST /api/v1/auth/forgot-passwordalways returns 202{ "sent": true }, even for unknown or unconfirmed addresses.- Password reset is only sent to accounts with an assigned and confirmed address.
check-usernameis intentionally public, rate limited, and only reports availability.
Rate limiting
| Limiter | Window | Limit |
|---|---|---|
| Auth (register, login, reset-password) | 15 min | 30 |
| Username check | 1 min | 60 |
| E-mail (forgot-password, resend, verify) | 15 min | 8 |
| Chess | 1 min | 120 |
| Leaderboard read | 1 min | 120 |
| Leaderboard write | 1 min | 30 |
Rate limits depend on the real client IP. Correct TRUSTED_PROXIES configuration is required; otherwise every request may appear to come from the proxy and share one bucket.
CORS
CORS_ORIGINS is a comma-separated allow-list. When it contains *, every origin is allowed. Production deployments should list the exact web origin:
CORS_ORIGINS=https://games.example.com
Allowed methods: GET, POST, PATCH, DELETE, OPTIONS. Allowed headers: Content-Type, Authorization.
HTTP hardening
helmet()sets standard security headers.x-powered-byis disabled.- JSON bodies are limited to 16 KB; larger requests return 413.
- Request logs strip query strings and never include bodies, tokens, passwords or e-mail addresses.
- Failed logins log only the client IP.
- The verification page sets
noindex,nofollow. - The management panel sets
Cache-Control: no-store.
Trusted proxies
TRUSTED_PROXIES accepts:
- IP addresses.
- CIDR ranges.
loopback,linklocal,uniquelocalpresets.
Invalid entries fail startup. When set, it takes precedence over the legacy TRUST_PROXY.
Do not use TRUST_PROXY=true on a public server: a client could spoof X-Forwarded-For and bypass IP-based rate limits.
Management panel
- Read-only HTML.
- HTTP Basic authentication.
- Disabled entirely when
MANAGEMENT_PASSWORDis empty. - Username defaults to
admin. - Store the password in a root-only file, never in Git.
- Restrict
/managementat the reverse proxy or with network policy where possible. - It exposes aggregate counts and recent activity, not password hashes or tokens.
SMTP security
- Implicit TLS on port 465.
- STARTTLS on ports 25, 587 and 2525.
SMTP_SECURE=trueon a STARTTLS port is auto-corrected with a warning.- TLS server name is set to
SMTP_HOST. - Connection, greeting and socket timeouts are bounded.
- SMTP credentials live only in
.env.
A TLS mode mismatch produces SSL routines:tls_validate_record_header:wrong version number. Fix the port/mode combination rather than disabling TLS.
Chess integrity
- Every move is replayed from the start with
chess.js. - Turn order is checked against the stored match.
- Illegal moves return 400.
- Stale writes return 409 using an optimistic
versionfield. - Results (checkmate, stalemate, draws) are computed server-side.
- The client is never trusted for legality or outcome.
Leaderboard integrity
- Game IDs are restricted to the ten ranked games.
- Mode, seconds, moves and detail are validated and bounded.
- One row per user, game and mode; worse scores do not replace better ones.
- Only public fields are returned: username, time, moves, detail and date. E-mail addresses and UUIDs are not exposed.
Scores are client-reported and cannot be proven without server-side gameplay. Treat the board as a friendly competition, not an anti-cheat system.
Secrets
Never commit:
.envJWT_SECRET- PostgreSQL passwords
- SMTP credentials
MANAGEMENT_PASSWORD- Database dumps
- Release artifacts containing credentials
.env.example contains placeholders only.
Dependency security
npm audit --omit=dev
At release 1.4.0, the production dependency audit reports zero vulnerabilities. Re-run it before every release and update dependencies deliberately.
Universal Gaming Accounts
- Home
- Deployment
- Configuration
- API Reference
- Database and Migrations
- Security
- Operations
- Development and Testing
- Troubleshooting
Repositories
The accounts backend is optional. Universal Gaming remains fully playable offline without it.