Skip to content

Security model ​

Backlog Hold'em uses anonymous, room-scoped browser sessions. These are suitable when an invite code and display name are enough; they are not user accounts or organization identities.

Trust model ​

  • A room is discoverable by its six-character invite code.
  • Create/join returns a public participant ID and sets the separate opaque participant credential in a room-specific cookie.
  • Participant cookies are HttpOnly, SameSite=Strict, and Secure by default. JavaScript cannot read them, and participant credentials are not stored in localStorage.
  • The browser automatically includes the appropriate cookie with same-origin HTTP commands and WebSocket handshakes.
  • Reloading a room restores the public participant ID through GET /api/rooms/{code}/session after the backend verifies the cookie.
  • The domain checks whether that participant belongs to the room before reads or subscriptions, and whether they are the facilitator for facilitator-only commands.
  • State-changing HTTP requests reject a mismatched browser Origin, and WebSocket upgrades use same-origin validation.
  • Public room snapshots hide every current vote value and revision count until reveal.

What is protected ​

Vote secrecy and credential removal are enforced in the backend by Room.Snapshot, before data reaches SSE or WebSocket adapters. The UI does not receive participant credentials, other participants' hidden values, or revision counts. The final session summary is available to room participants; detailed revealed history remains in reporting projections.

Facilitator-only operations are checked by domain methods rather than by button visibility alone.

Current limitations ​

  • Participant cookies contain anonymous room bearer credentials. They end with the browser session and stop authorizing when the participant or room no longer exists, but there is no explicit logout or credential rotation.
  • There are no user accounts or organization boundaries. Anyone with a room code can join. Participant credentials identify anonymous room sessions, not verified people.
  • Command budgets are per process. Keep one application instance until cross-instance realtime delivery and coordinated rate limiting are supported.
  • Room expiry bounds inactive data retention, but active rooms and their histories can continue growing. Monitor database size and back it up.

Application protections ​

  • API bodies and incoming WebSocket messages are capped at 16 KiB. HTTP consumes the complete body, rejects trailing JSON/data, and returns 413 for oversize bodies. Commands without arguments accept an empty body or a JSON object. Invalid command input returns 400; oversized WebSocket messages close the connection with code 1009.
  • Display names are limited to 40 Unicode code points, room names to 120, and topics to 2,000. Participant IDs, deck names, and vote values are bounded; the domain checks membership, facilitator permissions, and allowed cards. Shared domain validation covers both HTTP and WebSocket commands.
  • The Go server caps headers at 32 KiB, allows five seconds to read headers and 15 seconds to read API bodies, and has a 75-second keep-alive idle timeout. SSE and WebSocket writes have 15-second deadlines per write, without a blanket stream lifetime limit.
  • Authenticated HTTP and WebSocket commands share participant and room token buckets. Defaults are 10 units/second per participant and 100 units/second per room, with ten seconds of burst capacity. reset, topic, and demo-team cost ten units because they can schedule demo work; other commands cost one. Rejected authenticated commands also consume the budget when admitted. HTTP returns 429 with Retry-After: 1; WebSocket sends an error acknowledgement without closing the connection. The client displays command errors without automatically retrying them.
  • Set PARTICIPANT_COMMAND_RATE and ROOM_COMMAND_RATE to tune units/second (integers from 10 to 10,000). Limiter state is capped at 10,000 buckets, fails closed when full, and sweeps entries idle for a minute. These limits protect command execution; ingress limits are still needed for unauthenticated requests and connection establishment.

Security audit events ​

The application records what action was attempted and whether it succeeded, without copying secrets or command contents into the audit log. For example, a vote event records that a participant voted and whether the command was accepted, but never the card they chose or the cookie that authenticated them.

The application emits structured security.command events through the existing JSON logger after HTTP command completion and for each WebSocket command. These cover create/join, votes and facilitator actions, authentication/authorization failures, invalid input, origin rejection, and throttling. Successful mutation events are emitted after persistence succeeds.

Included in audit eventsExcluded from audit events
Action, transport (HTTP or WebSocket), outcome, and reasonRequest bodies, names, topics, and vote values
Timestamp and request/trace IDs when availableCookies and participant credentials
Authenticated public participant_id where knownRaw room invite codes
A temporary room_ref for correlating events from the same roomDomain event payloads containing private state

The public participant ID is an identifier, not a credential: it cannot authenticate a request. room_ref is an HMAC-derived reference using a random process key, so it correlates a room within one process lifetime without exposing its short invite code. References change after restart.

These exclusions apply to audit logs, not to application storage. Domain events remain in the database for replay and include private state; do not export their payloads into operational logs. Request access logs remain separate from audit events.

Rejection audit output is capped at 100 events/second per process. Excess events are counted in suppressed_events on the next emitted rejection. This is operational security logging, not a lossless compliance audit trail. Retention, access control, and log rotation belong to the hosting platform.

Before internet exposure ​

For a public deployment, put a reverse proxy in front of the application. This is the internet-facing service that accepts browser connections, provides HTTPS, and forwards requests to the app over a private network. The application image runs as a non-root user, but does not provide HTTPS itself.

Start with the production Docker Compose example, which supplies Traefik as the reverse proxy and SQLite storage. An optional PostgreSQL variant is available. If you already use a hosting platform or reverse proxy, configure the equivalent protections there.

Who handles what? ​

ProtectionResponsible componentWhat it means
HTTPS and certificate renewalReverse proxy / hosting platformEncrypt browser traffic, redirect HTTP to HTTPS, and keep certificates valid.
Private backend accessHosting network and reverse proxyMake the app reachable through the proxy, without a public backend or database port.
Incoming traffic limitsReverse proxyLimit requests and open streams per IP, including room creation and joining.
Command validation and budgetsApplicationCheck input, membership, and facilitator permissions; limit commands over both HTTP and WebSocket.
Security audit eventsApplicationRecord actions and outcomes without credentials, invite codes, or command contents. See Security audit events.
Log storage and backupsHosting platform / operatorConfigure safe proxy logs, log access and retention, and recoverable database backups.

Protect the connection and keep the backend private ​

  • Serve the public site over HTTPS. Configure certificate renewal and redirect HTTP to HTTPS. Once HTTPS works, enable HSTS, which tells browsers to keep using HTTPS for this hostname.
  • Keep COOKIE_SECURE=true. Participant cookies authorize room actions. This setting prevents browsers from sending them over unencrypted HTTP; false is for local HTTP development.
  • Expose only the proxy. In the Compose example, only Traefik publishes ports 80 and 443. Do not publish the app's port 8080 or a database port to the internet, where requests could bypass the proxy's protections.
  • Preserve the public hostname. Forward the original Host so the application's browser-origin checks see the hostname users actually visit. If another load balancer sits before your proxy, trust forwarding headers only from that load balancer's configured IP ranges. Otherwise, clients could supply a false source IP and evade per-IP limits.

Limit traffic without breaking live updates ​

The proxy and application enforce different limits. The proxy limits incoming requests, including unauthenticated create/join attempts, and the number of open live-update connections. The application limits authenticated commands per participant and room.

The production example starts with 20 requests/second per IP, 30 create/join requests per minute per IP, and 100 concurrent streams per IP, with burst allowances described in the deployment guide. Tune these for expected usage: an entire office may share one public IP address.

Both live-update transports need proxy support:

  • Server-Sent Events (SSE): forward updates immediately rather than buffering them, and allow long-lived connections.
  • WebSocket: allow the initial HTTP connection to upgrade to WebSocket. Ordinary HTTP rate limiting sees that handshake, but not the commands sent afterward; the application's command budgets cover those commands.

Keep one application instance. Command budgets and realtime delivery are process-local, so adding replicas would require shared rate limiting and cross-instance event delivery.

Keep logs and stored data under control ​

The application omits sensitive data from its audit events, but your proxy and log collectors need their own configuration. Keep cookies, authorization headers, request bodies, and URL paths containing invite codes out of operational logs. The production example keeps only basic proxy metadata such as time, method, status, duration, and router name.

Restrict log access to operators and decide how long logs are retained. The example rotates container logs, but centralized collection and retention are hosting responsibilities. Back up the database and test restoring it into a separate deployment; database events contain private room state even though audit logs do not. See Backups, upgrades, and verification.

Check the deployment before sharing it ​

Verify that HTTP redirects to HTTPS, participant cookies have the Secure flag, and the backend and database ports cannot be reached externally. Create and join a room, reload it, and check that live updates still arrive after an idle period using both SSE and WebSocket. Confirm that excessive requests receive 429 responses and logs contain no credentials, invite codes, or vote values. The deployment verification guide includes an optional automated smoke check for a dedicated test deployment.

Finally, HTTPS does not change who can join: anyone with a room code can join that room. This deployment uses anonymous invite-code-based sessions. OpenID Connect (OIDC) is not required for that model; verified identities, single sign-on (SSO), or organization-only access would require a separate authentication and authorization design.

Backlog Hold'em project documentation