What is actually implemented in BeCoMe to keep the product and its users' data safe, and
how to operate the pieces that need runbooks. This is a description of the running system,
not a wishlist. Deployment topology lives in environments.md; the
academic threat model (attack trees, residual-risk argument) lives in the thesis
workspace and is not duplicated here.
| Area | Status | Where |
|---|---|---|
| Authentication -- JWT, refresh rotation, lockout | Implemented | api/auth/ |
| Session transport -- HttpOnly cookies + CSRF | Implemented | api/auth/cookies.py, api/middleware/csrf.py |
| Authorization and tenant isolation | Implemented | api/dependencies.py |
| Rate limiting and abuse control | Implemented | api/middleware/rate_limit.py |
| Input validation | Implemented | api/schemas/ |
| Configuration invariants per profile | Implemented | api/config.py |
| Network and edge -- Cloudflare, CORS, headers | Implemented | api/main.py, Cloudflare |
| Logging, observability, and PII scrubbing | Implemented | api/logging_config.py, api/logging_context.py |
| Secrets management and rotation | Implemented | Railway variables, below |
| GDPR export and erasure | Implemented | api/routes/users.py |
| Database least-privilege, network, auditing | Implemented | scripts/db/, api/db/engine.py |
| Automated database backups | Partial -- manual dumps | scripts/db/backup.sh |
Sessions are stateless JWTs signed with SECRET_KEY (HS256). Login returns a short-lived
access token and a longer-lived refresh token. Refresh tokens rotate: each refresh mints a
fresh pair tied to a rotation family (a sid claim). Presenting a refresh token that has
already been rotated is treated as theft and revokes the entire family, so a stolen token
stops working the moment the legitimate client refreshes. Changing the password stamps a
per-user cutoff that rejects every token issued before it. Both the change and the reset
route record that cutoff before they write the new password, so a revocation-store fault
aborts the operation instead of leaving a fresh password with every old session still
alive. The cutoff itself expires
once the longest-lived token would have, so it is not stored forever.
Passwords are hashed with bcrypt. Because bcrypt silently truncates input at 72 bytes, the
password is SHA-256 pre-hashed and base64-encoded first (api/auth/password.py), which is
the approach bcrypt's own maintainers recommend. A strength policy is enforced on
registration and password change.
Two abuse paths on the login endpoint are closed. Repeated failures lock the account for a
cooldown window (api/auth/login_throttle.py), which stops online brute force and
credential stuffing against a single account. And an unknown email still runs a dummy hash
so a wrong-email response takes the same time as a wrong-password one -- login timing
cannot be used to enumerate which addresses have accounts.
An account is created unverified and cannot log in until the address is confirmed. Login
checks email_verified_at only after the password verifies, and answers 403; checking
first would answer differently for an address that has an account and one that does not,
which is the enumeration oracle the uniform 401 exists to prevent.
POST /auth/register always answers 202 with one fixed body. Behind it,
RegistrationService picks one of three branches: a free address creates an unverified
account, a taken-but-unverified address writes nothing at all, and a taken-and-verified
address is left untouched while its owner is emailed a notice that someone tried to sign up
with it.
A demo service account answers as a fourth kind of address, without needing a fourth
branch. Thirteen such accounts hold the opinions of every user's seeded example project
(is_demo on users, api/data/example_project.py), and select_account_by_email
(api/services/query_helpers.py) excludes them from every lookup that resolves an address --
login, registration, invitation, password reset, and activation resend alike -- so each of
those five paths answers a demo address exactly as it answers one nobody has ever registered.
Registration therefore reads it as free: _create_account attempts the insert, loses it to
the unique index the demo row already occupies, and falls through the same IntegrityError
handling a race between two real submissions takes (api/services/registration_service.py),
landing on the uniform 202 with nothing to activate. The exclusion is what makes this a
security control rather than a display convenience -- a lookup that forgot it would let an
outsider invite a demo account into a real project or, had the migration not also inserted
every demo row already verified, claim one outright through the taken-but-unverified branch
and read every project it sits in. Both defenses hold independently: is_demo filtering keeps
the accounts from being found at all, and pre-verification means even a lookup that did find
one could not fall into the branch that mints it an activation token.
A submission is bound to the link it mints, never to the account. The activation token
carries the submitted password hash and names (email_verification_tokens, all three
columns NOT NULL), and they are written to the account only when that specific token is
redeemed. The alternative -- storing the newest submission on the account row -- is an
unauthenticated account-takeover primitive: whoever submits last would decide what every
outstanding link opens, so an attacker submits victim@example.com after the real owner
signed up, the owner clicks the link they already have, and the account activates on the
attacker's password.
Redemption also requires the password the token carries. POST /auth/verify-email takes
{token, password, language} and checks the password against the token's hashed_password,
never against the users row. That is what closes the class the binding above only narrows.
Anyone can cause an activation link to arrive at an address they do not control -- a repeat
registration puts a fresh one at the top of the victim's inbox, and nothing distinguishes it
from their own. With the password required, the stranger's link is dead in both hands: the
recipient does not know the submitted password, and the submitter never sees the link. The
victim's own link, with the victim's own password, still works. Neither party can activate
the other's submission.
Three consequences follow. Issuing a link does not retire the outstanding ones, unlike
password reset -- several live links for one unconfirmed address is the correct state, since
each is single-use and carries its own submission, and retiring them is exactly how one
submitter would kill another's pending link. Redemption is refused once the address is
verified, with the same opaque 400 an unknown token gets: a token minted while the account
was unconfirmed carries a password, so redeeming it afterwards would rewrite the credentials
of an account somebody is already using -- and refusing before the password is weighed also
keeps the endpoint from being able to lock a live account out of its own login. And a
completed password reset sets email_verified_at too, which retires every outstanding
activation link in one move; a reset link proves control of the address exactly as an
activation link does, so reclaiming an address somebody pre-registered is one step for the
credentials. It is not one step for the display name -- see "Accepted risks" below.
That makes an activation and a reset the two operations that can confirm the same account, so
both go through a _claim_account that writes UPDATE ... WHERE email_verified_at IS NULL
whenever the row it loaded was still pending, and lets the database pick the winner. The loser
writes nothing and gets the opaque 400; on the reset side its token is deliberately left
unspent, so following the same link again succeeds against the now-confirmed account. A reset
whose account was already confirmed when its token resolved has nothing to race -- redemption
is refused once email_verified_at is set -- so it writes by id alone. Reading the row and
writing it back would instead let a reset that started while the account was still pending
overwrite the password an activation had just applied, seconds after telling whoever redeemed
it to sign in; bcrypt alone holds that window open for a few hundred milliseconds.
A wrong password answers 403 with its own wording, not the opaque 400 the token errors
share. Whoever reaches that point already holds a live link, so admitting the link is fine
tells them nothing new -- while telling a user who mistyped that their link is broken would
send them round the loop asking for another one that would fail the same way. The guessing
oracle that opens is capped by its own per-token lockout (api/auth/login_throttle.py),
namespaced apart from the one /login uses: this endpoint's lockout can only be tripped by
someone who already holds a live token, so a run of failed logins -- which anyone who merely
knows the address can produce -- can never deny someone their own activation.
The lockout keys on the token's hash, not the account. Issuing a link never retires an
earlier one (see above), so one unconfirmed address can carry several live tokens at the same
time. A single bucket shared by all of them would reopen, one level down, the same shape of
denial the login/activation split above already prevents: anyone who merely obtains a single
token -- forwarded by its recipient, or intercepted, rather than read from the mailbox itself
-- could spend the account's entire activation budget against it and, because every failure
refreshes the window, keep it spent indefinitely, locking the real owner out of a different,
freshly resent link they hold instead. Keying on the token confines that damage to the token
it was spent against. It does not widen what one token can be guessed: any single token still
allows at most ten activation failures against its own password, the same bound described
below. Nor does it hand an attacker more guesses to spend -- resend-verification only ever
mints a token carrying the password its own caller submitted, and the mail carrying it goes to
the account's address, not back to whoever asked, so minting more tokens never buys a guess
against a password the caller does not already know.
The two budgets are independent, and they add up. Each endpoint allows 10 failures per 15
minutes and consults only its own counter -- login's keyed per account, activation's per token
-- so nine failed logins followed by ten activation guesses against one token is nineteen wrong
passwords reachable against that token's password inside a window. A shared counter is the only
thing that would bound the total, and a shared counter is exactly what the split exists to
prevent. Moving the login counter takes no credential and every failure refreshes its expiry,
so any endpoint that reads it can be held shut indefinitely by a stranger who knows nothing but
the address. The activation counter cannot be moved without a live single-use token, and that
token exists in one place: the mailbox it was sent to. Whoever reaches the extra ten guesses has
therefore already read the mail, and reading the mail takes the account outright through
forgot-password without guessing at anything. The extra guesses hand an attacker a weaker
capability than the one they used to get them. A mismatch does still spend from the login
counter, which costs the guesser instead of bounding them: ten activation mismatches lock
/login too, so the total only grows when the login guesses come first.
POST /auth/reset-password clears the login lockout on success, so an attacker's failed
guesses cannot keep an account locked out of login after its owner has proven control of
the address and set a new password.
The notice mail carries a static /forgot-password link, never a minted reset token -- an
unauthenticated registration attempt must not be able to mail anyone a working reset link.
Two side channels are closed alongside the response body. Every branch runs bcrypt on the
submitted password, including the one that writes nothing, so none can be identified by
answering hundreds of milliseconds faster. And the emails the flow can trigger share one
per-address budget (api/auth/email_throttle.py, keyed by a hash of the address), so
submitting a victim's address in a loop from rotating IPs cannot flood their inbox past the
per-IP limiter. Suppression never changes the response.
Every send spends from that budget, including the one on the branch that creates the account
-- which is the only branch never denied by it. The distinction matters in both directions.
Never denied, because that branch fires at most once per address (a second submission is by
definition no longer free), so charging it would let a stranger drain the allowance with five
unauthenticated resend-verification calls and pre-empt a signup that has not happened yet:
the victim would register, get the normal 202, receive nothing, and be left with an account
that cannot log in. Still spending, because a send that left the budget clean would make two
back-to-back submissions separate a free address from a taken one -- the free one mailing on
both probes, the taken one going quiet after the first -- and the awaited round trip to the
mail provider is exactly the difference the uniform 202 exists to hide. A resend request
for an address with nothing to send spends nothing, for the same anti-denial reason as the
creating branch, and it mails nothing either, so it opens no timing difference.
Activation tokens otherwise follow the password-reset design: secrets.token_urlsafe(32),
only the SHA-256 hash stored, single use, expiring after
EMAIL_VERIFICATION_TOKEN_TTL_HOURS (24 by default). Unknown, spent, and expired tokens all
get one opaque 400. POST /auth/resend-verification takes {email, password} and answers
202 identically for an unknown address, an unverified one, and an already-verified one.
Taking the password makes it a repeat registration minus the names: the link it mails carries
that password like any other, and the names come from the account, which is all a resend
request can know. An address-only resend would mint a link with nothing to prove, which
anyone receiving the mail could follow.
Before any of that touches the database, the submitted address goes through
EmailAddressPolicy (api/services/email_policy.py): a vendored disposable-domain
blocklist and an MX/A/AAAA reachability check that fails open. Only a definitive negative
rejects: NXDOMAIN, a confirmed absence of every record type, or an RFC 7505 null MX, which is
the domain itself declaring that it accepts no mail and which overrides the A/AAAA fallback
the way the RFC requires. Both verdicts depend only on the domain string, never on database
state, so their 400 leaks nothing about account existence. Each has a runtime kill switch
(DISPOSABLE_EMAIL_BLOCKING_ENABLED, MX_CHECK_ENABLED) in case either starts rejecting
real users.
language on /auth/verify-email carries no authority and needs none. A successful
redemption seeds the newly activated account's example project (ExampleProjectService.seed_for),
and language only picks which pre-written variant of that project's text is used. The
request schema constrains it to the Literal["en", "cs"] the seeded copy actually ships in,
so it never reaches the database as free text and cannot widen anything the token/password
pair above already decided. Seeding runs after activation succeeds and its own failure is
swallowed (api/routes/auth.py): an account that verified but got no example project is an
acceptable outcome, an account that could not be logged into because seeding raised is not.
The browser client keeps no token in JavaScript-readable storage. Login and refresh set the
access and refresh tokens as cookies marked HttpOnly, SameSite=Strict, and Secure. An
XSS payload therefore cannot read the session the way it could read localStorage.
The names carry prefixes the browser enforces: __Host-access_token,
__Host-csrf_token, __Secure-refresh_token. __Host- is the one that matters — a cookie
under that prefix is only accepted with Secure, Path=/ and no Domain, and can only be
written by the exact host it belongs to. That closes the gap SameSite leaves open: a page
on any sibling becomify.app subdomain counts as same-site here, so without the prefix it
could write a session cookie of its own and log the victim into the attacker's account
(session fixation) — a different attack from the CSRF one below, and one no amount of token
derivation prevents. The refresh cookie takes __Secure- instead, because __Host- would
force Path=/ and hand it to every request on the site rather than the auth routes alone.
Two consequences worth knowing. Secure is now unconditional rather than following the
request scheme: a __Host- cookie without it is not a weaker cookie, it is one the browser
discards. And the deletions in clear_auth_cookies carry the same attributes, because a
deletion is a Set-Cookie like any other — one sent without Secure is rejected outright,
and logout would answer 204 with the session cookie still in place.
That is also why the e2e suite runs over HTTPS (frontend/scripts/e2e-cert.mjs mints a
throwaway certificate). Browsers treat localhost as a secure context and accept Secure
cookies there — except WebKit, which refuses to store them over plain http://localhost at
all, so on HTTP every authenticated Safari-engine test would fail on a cookie that was
never stored. Verified across all three engines before adopting the prefixes.
SameSite=Strict already stops the session cookies from riding along on cross-site
requests. On top of that, a CSRF check (api/middleware/csrf.py) defends the same-site
case, and the case SameSite does not cover at all: "site" means the registrable domain,
so a page on any becomify.app subdomain is same-site with the API and its requests do
carry the session cookies.
The token is derived from the session, not drawn at random and stored beside it:
csrf_token_for(sid) is an HMAC of the session id under the application secret
(api/auth/cookies.py). On a mutating request the middleware reads the session cookie,
takes the sid from it, recomputes the expected token, and compares it against the
X-CSRF-Token header. That comparison is against a value the server derives, never
against something the client sent.
The difference matters against an attacker who can write cookies for the API host -- one
sitting on a sibling becomify.app subdomain, since a cookie set with
Domain=becomify.app shadows ours. A plain cookie-versus-header comparison loses to that
attacker twice: they can plant a value they know in the csrf_token cookie and echo it in
the header, and they can equally plant a token minted for a session they legitimately
hold. Binding the token to the victim's sid closes both, since forging one for a session
you do not hold means forging an HMAC.
Deriving it also means the check cannot be waived by omission. It keys on the session
cookie, so any request that authenticates as somebody is checked -- where the old
comparison armed itself on the presence of the CSRF cookie and silently passed anything
arriving without it. Bearer-token clients carry no session cookie and are unaffected, as
are the pre-session auth endpoints (login, register, refresh, password reset), so a
stale cookie left by a revoked session can never block a fresh login. Logout stays
CSRF-protected.
Reading the sid deliberately skips expiry, revocation, and the per-user cutoff
(session_id_from_access_token): the signature is verified, but the rest is the
authentication layer's job, and checking it here would put three Redis round trips in
front of every mutating request. A token failing any of them is refused moments later
anyway, with the CSRF check already applied.
The SPA gets the value from a response header rather than from the cookie. Login and
refresh send it in an X-CSRF-Token response header, and GET /auth/me -- the app's
session probe on mount -- reports the token for the session it authenticates as, so a page
reload recovers it (api/auth/cookies.py::set_csrf_header). The header is what makes this
workable on the deploys: the cookie carries no Domain attribute, so it belongs to the API
host, and the app is served from a different one, where document.cookie shows it nothing
while the browser keeps sending it. Reading the cookie still works when the two share an
origin, which is how local development runs behind the Vite proxy, and stays the fallback.
CORSMiddleware names the header in expose_headers; without that the browser withholds
it from cross-origin JavaScript.
Handing the value back changes nothing about the guarantee. It is meant to be readable by
the client that owns the session, and CORS answers against an explicit origin allow-list,
so a hostile origin can no more read the header than the cookie. Widening the cookie with
Domain=becomify.app would look like the smaller fix and is the one to avoid: dev,
staging, and production all sit under that parent and would overwrite each other's token,
breaking whichever was visited last. Deriving the token also retired a sharp edge on
/auth/me, which used to echo the caller's own cookie back: Starlette's RFC 2109 cookie
unescape turns csrf_token="\012..." into a real newline, and uvicorn writes response
headers without validating them, so that echo was a response-splitting primitive kept in
check by a filter. There is no client value on that path any more.
The other half of the same threat — an attacker on a sibling subdomain shadowing the
session cookie rather than the CSRF one, which logs the victim into the attacker's
account instead of forging a request — is covered by the __Host- prefix described at the
top of this section. Deriving the CSRF token would not have helped there: the two defences
answer two different attacks and both are needed.
The Authorization: Bearer path is kept alongside the cookie path for programmatic clients
and the test suite; it authenticates the same tokens without the cookie or CSRF machinery.
Such a client sends no session cookie, so it gets no header back either.
BeCoMe is multi-tenant: every project-scoped route runs through RequireProjectAccess
(api/dependencies.py) before it reads or writes anything. A caller who is not a member of
the project gets 404 Not Found -- the same answer as a project that does not exist -- so
the API never confirms the existence of a project to someone with no business knowing about
it. A member who lacks the required role (an expert attempting an admin action) gets 403,
since they already know the project is there. Denials are logged with the project and user
id for audit.
Row-level security is deliberately off (migration c83186b79ad7): the tables arrived from
Supabase with RLS enabled and forced but no policies, which is a no-op for a superuser and
a total lockout for the least-privilege become_app role. Authorization therefore has no
database backstop -- if a route does not check, nothing does -- so
tests/unit/api/test_route_authorization.py enforces it structurally instead of trusting
review. It walks the application's real route table and asserts two things: every route
taking a project_id has RequireProjectAccess somewhere in its dependency tree, and
every route taking any path parameter is either guarded the same way or listed in
UNGUARDED_BY_DESIGN with the check that stands in for the dependency.
The second guard exists because the first cannot see the routes that matter most here: an
identifier like {invitation_id} points at somebody else's record just as squarely as a
project id does, and carries no project_id for the first check to notice. Three routes
are exempt today -- the public avatar proxy, and accepting or declining an invitation,
where InvitationService compares invitee_id against the caller because there is no
membership to check until the invitation is accepted. A new route naming an object fails
the suite until somebody either wires in the dependency or records what replaces it.
Rate limiting is handled by slowapi backed by Redis, with an in-memory fallback if Redis is
unreachable (api/middleware/rate_limit.py). Two global ceilings apply to every route --
300/minute and a 20/second burst -- so no endpoint is unbounded even if it forgot its
own limit. On top of that, routes carry tighter limits by risk:
| Limit | Value | Applied to |
|---|---|---|
| Auth | 5/minute |
Login, register |
| Password reset | 3/minute |
Forgot / reset password, verify / resend verification |
| Write | 30/minute |
Mutations that also recalculate |
| Upload | 10/minute |
File uploads |
| Standard | 60/minute |
Everything else |
The limiter keys on the real client IP. Behind Cloudflare and Railway the app trusts the
forwarded protocol and client address only from the proxy hop it is configured for; a
X-Forwarded-For header from an untrusted source is ignored, so a client cannot spoof its
way around the per-IP limits. Auth rate limiting fails closed. Request bodies over the
configured ceiling are rejected with 413 before they are buffered, and profile-photo
uploads are streamed and size-capped rather than read whole into memory.
A photo upload passes three independent checks (api/services/storage/validation.py): the
declared content type must be one of four image types, the leading magic bytes must match
that declaration, and the pixel canvas must fit the avatar budget (4096x4096, no side over
8192). The last one exists because bytes and pixels are not the same limit: image formats
compress uniform areas so well that a 35 KB PNG can declare a 25-megapixel canvas, which
costs hundreds of megabytes the moment anything decodes it. Only the header is parsed, so
rejecting such a file is free.
Request DTOs are Pydantic models with extra="forbid" (api/schemas/), so an unexpected
field is a 422 rather than a silently ignored parameter. String fields carry length caps,
free-text names are HTML-sanitized, and list endpoints clamp their page size
(MAX_PAGE_SIZE = 100, api/pagination.py) so a single request cannot ask the database for
an unbounded result set. The GDPR export is the one deliberate exception to the list caps --
Article 20 requires the full dataset -- and that is documented at the call site.
The app runs under one of three profiles selected by APP_ENV: dev, test, prod
(api/config.py). Deployed services are held to invariants that fail startup rather than
boot insecurely (_validate_deploy_invariants): the SECRET_KEY must be strong, SQLite is
rejected in favor of PostgreSQL, redis_url must be set, CORS_ORIGINS may not be
localhost, a real email provider must be configured, DEBUG must be off, and
MIGRATION_DATABASE_URL must be set explicitly so migrations keep running as the
privileged role. Production additionally requires cloudflare_origin_secret.
"Deployed" is not the same as "not dev". The dev service has its own database and a
public URL, so it is held to the same invariants: anything running with a
RAILWAY_ENVIRONMENT_NAME counts, whatever its APP_ENV. A laptop and a CI runner carry
no such marker and stay unconstrained. The pytest runner is distinguished from a deployed
test (staging) environment by a TESTING flag, so the invariants guard staging without
breaking the local suite. Interactive API docs (/docs) are served only outside
production.
The public origin is becomify.app, served through Cloudflare, and each deployed
environment has its own host under it: api.becomify.app (production),
api-harbor.becomify.app (staging), api-atelier.becomify.app (dev). A Cloudflare
Transform Rule per host injects a shared secret header the origin then requires
(cloudflare_origin_secret, a different value per environment, demanded of every deploy
by Settings._validate_deploy_invariants).
The lock decides what the origin believes, not what it accepts. Railway also gives each
service a *.up.railway.app address, and those answer, so the edge can still be bypassed;
what the secret buys is that a request arriving without it is keyed under a single constant
rather than off a forwarding header the origin cannot vouch for (api/utils/client_ip.py).
That matters because uvicorn runs with --proxy-headers --forwarded-allow-ips='*', which
makes request.client.host only as trustworthy as whatever wrote X-Forwarded-For. It
also makes the rate-limit key correct rather than merely safe: without the lock, every
request that did transit Cloudflare is keyed under the edge's address, collapsing all
callers into one bucket. Closing the bypass itself -- so the WAF and bot rules cannot be
skipped -- means retiring the *.up.railway.app domains, which is still open. CORS is configured
with explicit allowed origins and allow_credentials=True (required for the cookie
session). It permits the X-CSRF-Token header on the way in and exposes it on the way
out, since the SPA reads the CSRF token off the response.
Both tiers send security response headers: the API from middleware
(api/middleware/security_headers.py), the SPA from nginx (frontend/nginx.conf). Their
Content-Security-Policies match except where the SPA genuinely needs more. Its
connect-src names the API origin, which is cross-origin on the deploys, plus the Sentry
ingest hosts the browser SDK reports to. Its img-src names that same API origin, because
profile photos are served by the API and rendered in <img> tags -- an image load, which
connect-src does not cover. That origin is baked into the policy when the image is built,
from the same VITE_API_URL build argument the bundle uses, so the served policy cannot
drift from the URL the app actually calls. Both directives are asserted against the config
in tests/integration/test_frontend_csp.py: a CSP that under-permits fails silently, since
the browser drops the request before it reaches the origin and nothing appears in the logs. X-XSS-Protection is set
to 0 on both tiers on purpose: the legacy auditor it enables is unreliable, browsers have
dropped it, and its blocking mode has itself leaked cross-origin information. The CSP is
what constrains injection.
The correlation ID is not taken on trust either. An inbound X-Request-ID is echoed on the
response and written to every log record of the request, so it is reused only when it looks
like an ID -- a short, conservative alphabet -- and replaced with a fresh UUID otherwise
(api/middleware/request_logging.py).
Logs are structured JSON in the deployed profiles. Each request is tagged with an
X-Request-ID, and a context filter binds that request id and the acting user id onto every
api.* log record through contextvars (api/logging_context.py), so a service or security
log line can be traced back to a request without threading identifiers by hand. Personal
data is kept out of the logs: email addresses are recorded as a truncated keyed digest
(email_hash), not in the clear. The key matters -- a plain SHA-256 of an address is
reproducible by anyone holding the logs, so they could confirm whether a given person has
an account by hashing a guess. The tag is an HMAC keyed with LOG_HASH_KEY (falling back
to SECRET_KEY), which makes it meaningless outside the application while still
correlating repeated attempts on one account. Rotating the fallback re-tags every later
record, so set LOG_HASH_KEY explicitly where that correlation has to survive a rotation.
The rule for any new log record: identifiers, keyed tags, and counts, never the values
themselves. Allowed are user_id, project_id, invitation_id, jti, sid, ip,
status_code, duration_ms, row_count, and the reason a request was refused. Never
logged, at any level: passwords and password hashes, raw JWTs, the CSRF cookie or header
value, the email API key or bucket credentials, the Authorization header, any request or
response body, raw email addresses, activation or reset tokens and the URLs carrying them,
and free-text user content such as a project description.
Two specific traps are worth naming. First, _digest() in api/auth/login_throttle.py and
api/auth/email_throttle.py is a plain, unkeyed SHA-256 of the identifier -- it exists
to key the store, not to be logged. Writing it out would rebuild exactly the oracle
hash_email is keyed to prevent, so records in those modules name the flow (key_prefix,
op) and never the account; the address-scoped event belongs at the call site in
api/routes/auth.py, which has the keyed tag. Second, sqlalchemy.engine is pinned at
WARNING in api/logging_config.py and must stay below DEBUG on any deployed service: its
DEBUG level prints bound query parameters, which on this schema means bcrypt hashes,
addresses, names, and reset-token hashes shipped to the log drain in the clear. That pin
matters because the dev deploy now runs at LOG_LEVEL=DEBUG against a real database.
Both throttles fail open, and that is now alerted rather than silent. A Redis outage lifts
the per-account login lockout and the per-address email cap while the request still
succeeds, so each failure path logs throttle_store_unavailable naming the operation and
the flow. It is logged at ERROR, and that level is the alert: Sentry is initialised
without a LoggingIntegration, so the SDK's default event_level of ERROR is what turns a
record into an issue, and an issue is the only thing that pages. At WARNING these records
were a breadcrumb on some later event, which meant the brute-force lockout could stay off
for the length of an outage with nothing raised anywhere -- the request it happened during
returns normally, so there is no other symptom to notice. The accepted risk is unchanged;
what changed is that it now announces itself.
The level alone is not the whole alert, because the project's only other rule fires on
high priority issues and Sentry does not necessarily rank a logged error that high. A
dedicated rule, "Fail-open throttle: store unavailable" on the python-fastapi
project, matches any event whose message contains throttle store unavailable and
notifies on a new issue, a regression, or five events in an hour. That last condition
is what covers a second outage weeks later: by then the issue is neither new nor a
regression, and a rule without it would stay silent exactly when the lockout is off again.
The same applies to the revocation store, which fails closed: its errors surface to the
caller as a plain 401, so revocation_store_unavailable is the only thing separating a
Redis outage from a wave of bad tokens.
Unhandled errors hit a catch-all 500 handler and, when SENTRY_DSN is configured, Sentry.
Two separate switches keep credentials out of the tracker, and both are needed.
send_default_pii=False drops the client IP, cookies, headers, and request bodies.
include_local_variables=False drops the frame locals of every traceback frame, which
send_default_pii does not cover and which default to on: the auth handlers bind the parsed
request body to a local, so with locals enabled a fault anywhere under register,
change-password, or reset-password would ship repr() of that model -- the plaintext
passwords, or a reset token that is still redeemable -- to the tracker. Sentry's own scrubber
does not catch this, because it matches local names against a denylist and the leaking
local is called data. As a second layer, the credential fields in api/schemas/auth.py are
declared repr=False, so those values stay out of any repr() regardless of who calls it.
The browser SDK needs its own guard, since the reset link carries its token in the query
string: frontend/src/main.tsx installs beforeSend and beforeBreadcrumb hooks
(frontend/src/lib/sentry.ts) that redact token, access_token, and refresh_token
from event URLs and from navigation and fetch breadcrumbs.
Those hooks were inert until this was fixed. frontend/Dockerfile declared only
ARG VITE_API_URL, so VITE_SENTRY_DSN never reached the Vite build even though it was
set on all three frontend services; the if (import.meta.env.VITE_SENTRY_DSN) guard in
main.tsx folded to false and the whole Sentry.init call was tree-shaken out of every
deployed bundle. No browser error was reported from any environment. The scrubbers now run
because the SDK actually initialises -- so they are worth re-reading as live code rather
than as documentation of an intent.
Configuration comes from environment variables; the local .env is gitignored and never
committed, and deployment secrets live in Railway variables rather than in the image. CI
runs detect-secrets against a committed baseline, and the same check runs locally before a
push, so a credential added to the tree is caught before it leaves the machine.
Rotating the become_app database password without downtime uses a second credential so
there is never a window where the application cannot connect:
- Create a replacement login with the same grants --
CREATE ROLE become_app_v2 LOGIN ..., then apply thescripts/db/roles.sqlgrants to it. - Point the environment's
DATABASE_URLatbecome_app_v2in Railway and redeploy. - Confirm health is
200and thatpg_stat_activityshows connections from the new role. - Drop the old role:
DROP ROLE become_app;. Renamebecome_app_v2back during a later quiet window if you want the canonical name restored.
SECRET_KEY rotates the same way: add the new value, redeploy, verify, then remove the old
one. Rotating it invalidates every existing session, which is the point when a leak is
suspected.
Both data-subject rights are served by the API. Export (Article 20) returns the full
personal dataset as JSON from GET /api/v1/users/me/export. Erasure (Article 17) is handled
by DELETE /api/v1/users/me: the request carries a disposition for every project the user
still admins -- transfer it to another member, or delete it -- and the account is removed
only once each owned project has been dealt with. That keeps one person's erasure from
silently taking a whole panel's opinions with it. The profile-photo blob is deleted from
object storage as part of the same operation, so erasure covers stored media too.
When an admin removes a member from a project, that member's opinion is deleted in the same
transaction and the project result is recalculated without it (remove_member in
api/services/project_membership_service.py). Otherwise the row would outlive the
membership: it carries the person's name, email, and stated position, it is served to every
remaining member and embedded in every export, and RequireProjectAccess would no longer
let the person concerned withdraw it themselves.
Seven properties are known, deliberate, and reviewed. They are recorded here so a future audit does not re-litigate them.
Inviting by email discloses whether an address has an account. Inviting an address that
has no account answers 404, which is inherent to the flow: an invitation cannot be created
for a user row that does not exist. The bit disclosed is one the caller already supplied, and
the caller is an authenticated project admin rather than an anonymous prober. Registration
used to disclose the same bit through a 409; deferred activation closed it (see
"Registration and email verification" above).
A stranger's signup still puts an activation link in your inbox, and it looks like any other. Pre-registering somebody else's address is not something an unauthenticated endpoint can prevent, and the mail it triggers comes from us, to you, worded exactly as your own would be. What it no longer does is anything: following it requires the password behind the submission, which the stranger chose and you do not know, so the link is inert in your hands and unreachable in theirs (see "Registration and email verification" above). The residue is one unexplained email, plus a pending unverified account holding your address. Registering normally, or completing a password reset, takes the address over for login and kills every outstanding link -- though not the display name it was pre-registered under; see the next entry. Nothing about it is worth acting on, but a support question about "why did I get this" is a legitimate one and the answer is here.
Reclaiming a pre-registered account through a resend or a reset keeps the name it was
pre-registered under. create_resend_url takes first_name/last_name from the account
row, and a completed password reset never touches them either -- both confirm the address
and let the new owner set the account's password, but neither writes a name. If a stranger
pre-registered the address under a name of their choosing, that name is what
GET /auth/me returns afterwards, and what surfaces in project member lists and invitations,
until the account holder does one of two things: register the address again (which does write
the submitter's own names, the same as a free address) or edit the profile once signed in.
Only re-registering closes this in the same step that reclaims the credentials; a resend or a
reset does not.
POST /auth/register still has a timing signal, of one bit and only once. A submission
whose email is suppressed by the per-address budget skips an awaited round trip to the mail
provider, so it comes back measurably sooner than one that sends. A first probe can therefore
learn that mail was recently triggered for that address. It is weak and self-consuming: the
probe is itself a submission, so it spends from the same budget and changes the answer the
next probe gets, and it reports on recent activity rather than on whether an account exists
-- every branch spends a slot, so no sequence of probes separates a free address from a taken
one. POST /auth/resend-verification has the narrower version of the same property: it
awaits a send only when the address has a pending signup, so a first probe against a clean
budget distinguishes that case. Closing either means making the send fire-and-forget, which
is the same queue change forgot-password needs for the identical property.
A stranger who knows a pending address can use up its daily activation mail. The budget is
per address and shared by every send the flow can trigger, which is what stops an inbox being
flooded. The cost is that anyone who knows an address with a signup already pending can spend
the day's five unauthenticated resend-verification calls against it, after which the account
holder's own resend, and their own re-registration, mail nothing while still answering the
usual 202. It cannot be closed by checking the password first, because the check would
itself become the oracle the uniform response exists to remove. It is not a wedge: whatever
link was mailed before the drain still works, and forgot-password draws on a separate budget
and now confirms the address on success, so a password reset gets the holder in regardless.
Support's answer to "I asked for the email again and nothing came" is to use forgot password.
A Redis outage lifts the per-address email caps. RedisEmailSendThrottle answers True on
a RedisError (api/auth/email_throttle.py), so while the store is unreachable neither the
60-second cooldown nor the five-a-day total applies to forgot-password, register, or
resend-verification, and someone rotating source addresses can put more mail in one inbox
than the cap allows. The fail-open predates deferred activation and was written for recovery:
a store hiccup must never be able to swallow a password-reset email. Deferred activation
widened its reach, since registration and resend now draw on the same throttle, so an outage
now lifts the cap on activation mail as well. Failing closed is the worse trade. These
endpoints answer the same 202 whether or not mail went out, so a closed throttle would stop
every signup and every reset silently for the duration -- the acknowledgement would look
normal, nothing would arrive, and no error would be raised to alert on. The flood it would
prevent stays bounded meanwhile: the per-IP limiter survives a store outage by falling back to
slowapi's in-memory storage, which caps each source at LIMIT_FALLBACK, 60 requests a minute
per instance (looser than the 3 and 5 a minute those routes normally carry, so the outage does
widen this too). Redis is a hard requirement in every deployed profile, so an outage here is
short and is already paging someone. Handing the fallback to InMemoryEmailSendThrottle
instead was considered and dropped: it keeps a dict entry per address it has seen and never
evicts one, which would open a memory-growth path during exactly the outage it was meant to
cover.
Login and refresh also return the refresh token in the JSON body. The browser client
never reads it -- it uses the HttpOnly cookie -- but programmatic clients can only obtain
the token this way, and POST /auth/refresh accepts it in the request body for exactly that
reason. Reading the body value requires script execution on the origin, which the CSP
(script-src 'self'), the explicit CORS allow-list, and SameSite=Strict are there to
prevent; rotation with reuse detection then caps the value of a token that does leak.
How the PostgreSQL databases are locked down. This layer sits under the application and is operated through its own runbooks.
Every environment's database is reached through two roles, never one:
become_app-- the application runtime role behindDATABASE_URL. It is a plain login role:NOSUPERUSER,NOCREATEDB,NOCREATEROLE,NOBYPASSRLS. Its rights stop atSELECT/INSERT/UPDATE/DELETEon the application tables plusUSAGE/SELECTon their sequences. It cannot run DDL, create objects inpublic, or read another role's data.postgres-- the Railway-provisioned superuser, used only by Alembic for schema changes throughMIGRATION_DATABASE_URL. Railway hands out exactly one superuser, so there is no separatemigratorrole; keeping migrations on a distinct URL is what isolates DDL from the runtime token, without the overhead of in-sessionSET ROLE.
scripts/db/roles.sql is the idempotent source of truth for this setup. Running it against an
environment as postgres reconciles the grants, the default privileges that hand every future
Alembic-created table to become_app, the REVOKE CREATE ON SCHEMA public FROM PUBLIC
lockdown, a pinned search_path (pg_catalog, public, which closes CVE-2018-1058 for the
exposed role), and the role-level statement_timeout/idle_in_transaction_session_timeout/
lock_timeout. Passwords stay out of the file -- they live in Railway variables.
The connection itself is hardened in api/db/engine.py: TLS is required on deployed databases
(sslmode=require), each connection is tagged with an application_name, and the per-session
statement and idle-in-transaction timeouts are also set client-side through libpq options, so
a single runaway query cannot monopolise the database.
Production and staging databases answer only on Railway's private network
(*.railway.internal); their public TCP proxies were removed, so a leaked password is not the
sole barrier between the internet and the data. The dev database is the one exception, and even
there the proxy is normally closed -- it is opened in the Railway dashboard only for a specific
hands-on operation and shut again afterwards. That toggle is the project's substitute for an IP
allowlist, which Railway's platform does not offer.
Railway's managed Postgres permits ALTER SYSTEM, so connection auditing is enabled directly on
each database and reloaded without a restart:
ALTER SYSTEM SET log_connections = 'on';
ALTER SYSTEM SET log_disconnections = 'on';
ALTER SYSTEM SET log_statement = 'ddl';
ALTER SYSTEM SET log_min_duration_statement = '5000';
SELECT pg_reload_conf();Connections and disconnections are recorded, every DDL statement is logged -- schema changes
should only ever originate from Alembic, so anything else here is a red flag -- and statements
slower than five seconds are captured. The output flows into the Railway service logs. Finer
read/write auditing would need the pgaudit extension, but that requires
shared_preload_libraries, which Railway's image does not expose; log_statement = 'ddl' is the
workable substitute.
Railway's managed backups require the Pro plan, so on the current (free) plan the Postgres
volumes are not backed up automatically. Until that changes, take manual dumps with
scripts/db/backup.sh, which opens a temporary proxy, runs pg_dump, and removes the proxy
again:
./scripts/db/backup.sh prod # writes backups/prod-<timestamp>.dumpRun it before risky migrations and on a regular cadence. The dumps stay out of the repo
(backups/ is gitignored -- they contain user data).
That proxy is public while it is up. Railway's TCP proxy exposes the database on an
internet-reachable host and port, so for the length of the dump the only thing between the
production database and the internet is the become_app password -- the private-network
isolation described under "Network exposure" is not in force. The script removes the proxy
on every exit path, including failure, but a killed terminal or a crashed shell can still
leave one behind. Two habits follow: run it attended rather than from a cron, and if it
ever exits abnormally, check the service's TCP proxies in the Railway dashboard and delete
any that are still there.
Restore into any database with:
pg_restore --no-owner --no-privileges -d <target-url> backups/<dump>Upgrading the prod service to the Railway Pro plan would replace this with scheduled backups plus point-in-time recovery.
Deleting a user must not silently destroy other people's work. Most child rows clear
themselves through ON DELETE CASCADE (memberships, opinions, sent and received
invitations, reset tokens, activation tokens). A project a user admins is shared
data, so projects.admin_id uses ON DELETE RESTRICT: the database refuses to orphan
or silently erase a shared project, which is why the erasure endpoint requires an
explicit disposition for each one (see GDPR). Schema changes
ship as reversible Alembic migrations run through the migration-only superuser URL,
keeping DDL off the runtime role.