Security & Privacy on AWS
The short version: nothing you upload is world-readable, and nothing leaves your AWS account. This page says exactly how that is enforced, so you can check the claims rather than take them on faith.
Where your data lives
Section titled “Where your data lives”Everything stays inside your own AWS account, in the region you deploy to (eu-west-1 by
default):
| Data | Where | Notes |
|---|---|---|
| Games, results, posts, users | DynamoDB, one table per type | Point-in-time recovery enabled, 35-day window |
| Avatars and uploaded images | S3, one bucket | Served only through the app — see below |
| Secrets (JWT signing key, tokens) | SSM Parameter Store, SecureString | Encrypted at rest, fetched at cold start |
| Logs | CloudWatch, 30-day retention | Request metadata, no passwords or tokens |
There is no third-party analytics, no error-reporting service, no CDN other than CloudFront, and no outbound call except to Board Game Geek when you search for a game.
Nothing in the bucket is public
Section titled “Nothing in the bucket is public”The S3 bucket has all four public access block
settings on, and its policy grants read access to exactly one principal — the CloudFront
service — restricted to your distribution’s ARN. Requests straight to the bucket URL
return 403 even for a file that renders fine in the app.
Uploaded media requires a session. Avatars and images are not served from S3 by the
CDN. CloudFront routes /avatars/*, /blog-images/* and /game-images/* to the API,
which checks your session cookie before streaming the bytes. Fetch one without logging in
and you get 401, not the image.
That matters more than it sounds: avatar keys are just the username, so if they were served
publicly, anyone could guess /avatars/<name>.png for any member. The bucket policy also
carries an explicit Deny on those prefixes plus exports/, so a future misconfiguration
cannot quietly republish them.
Caching
Section titled “Caching”Two rules, both aimed at stopping a copy of your data resting somewhere it shouldn’t:
- API responses (
/api/*) are sentCache-Control: no-store. No proxy, CDN or browser history cache retains them, which is also what makes the back button after logout show nothing. - Uploaded media is sent
Cache-Control: private, max-age=1200.privatekeeps it out of CloudFront and every other shared cache; the 20 minutes applies only to the browser that authenticated for it, so a page of avatars isn’t refetched on every navigation. Avatar URLs carry a?v=stamp, so replacing a picture takes effect immediately.
In transit
Section titled “In transit”CloudFront serves HTTPS only and redirects HTTP. Every response carries HSTS
(max-age=63072000; includeSubDomains), X-Content-Type-Options: nosniff,
X-Frame-Options: DENY, a Content-Security-Policy limiting scripts to same-origin, and
Referrer-Policy: strict-origin-when-cross-origin. The TLS certificate is issued by ACM
and renews automatically.
The API is not reachable directly. API Gateway sits behind CloudFront, and the app rejects
any request that doesn’t carry the shared origin token CloudFront injects — so bypassing
the CDN to hit the origin returns 403.
Accounts and access
Section titled “Accounts and access”- No public signup. Accounts exist only if an admin creates them, so an internet-facing instance isn’t an open door or a spam target.
- Passwords are bcrypt cost-12 hashed, minimum 12 characters, never logged or returned.
- Sessions are JWTs in an
HttpOnly,Secure,SameSite=Strictcookie. Every request re-checks the user’stokenVersion, so logging out or runningscripts/revoke-sessions.pyinvalidates every session for that user immediately — including any image it could load. - Failed logins apply per-account exponential backoff on top of a 5-per-minute IP rate limit, and are recorded to a security log.
- Admin actions re-read the role from the database rather than trusting the token, so a demoted admin loses access at once.
What is deliberately public
Section titled “What is deliberately public”Only two things, and only if you leave them on:
- The landing page and the recommended games list, when
PUBLIC_RECOMMENDED_ENABLED=true(the default for the cloud deployment). Cover art on that page comes from Board Game Geek’s own CDN, not your bucket. Set it tofalseif you want nothing at all visible without a login. - The static frontend bundle — HTML, JS, CSS, icons. It contains no data.
Everything else, including every result, player, post and image, requires a session.
What is on you
Section titled “What is on you”The deployment is only as private as the account it runs in:
- Protect the AWS account itself. MFA on the root user, no long-lived access keys lying around. Anyone with account access can read DynamoDB and S3 directly, whatever the app enforces.
- Set
alert_emailinterraform.tfvarsand confirm the SNS subscription, or the CloudWatch alarms fire into a topic nobody receives. - Guard
infra/terraform.tfvarsand any table dumps. They are gitignored for a reason. - Rotate the JWT secret if you suspect it leaked; every session dies with it.
- Consider enabling CloudFront’s WAF Core protections — off by default, free to turn on,
and warned about (not blocked) by
terraform plan/applyif you don’t. See AWS Deploy for the one-click steps.
Verifying it yourself
Section titled “Verifying it yourself”None of this requires trust:
# Uploaded media rejects anonymous accesscurl -o /dev/null -w '%{http_code}\n' https://your-domain/avatars/<someone>.png # 401
# The bucket refuses direct readscurl -o /dev/null -w '%{http_code}\n' https://<bucket>.s3.<region>.amazonaws.com/avatars/<someone>.png # 403
# API responses are not cacheablecurl -sD - -o /dev/null https://your-domain/api/recommended | grep -i cache-control # no-store
# The origin is not reachable without CloudFront's tokencurl -o /dev/null -w '%{http_code}\n' https://<api-id>.execute-api.<region>.amazonaws.com/api/health # 403