Security

This document explains how Beyou defends itself: how users prove who they are, how every request is validated and throttled, how destructive actions demand a second factor, and which guards refuse to even start the server when production is misconfigured. It ends with an honest assessment of what is still missing.

One framing note first: the backend never terminates TLS. HTTPS, and therefore the safety of every cookie and header below, is the job of the Cloudflare Tunnel and the cluster's ingress in front of the backend, which has no public port of its own. The infrastructure topic covers that layer.

Security at a glance

flowchart LR
  subgraph client["Client"]
    FE["⚛️ Web / 📱 Mobile<br/>JWT in memory"]
  end

  subgraph filters["Request pipeline"]
    RL["🚦 RateLimitFilter<br/>bucket4j tiers"]
    SF["🛡️ SecurityFilter<br/>JWT validation"]
    DS["🔏 DocsImportSecretFilter"]
  end

  subgraph server["Server side"]
    LA["🔒 Login lockout<br/>per account"]
    TS["🔑 TokenService<br/>HMAC256, 15 min"]
    RT["🔄 Refresh tokens<br/>SHA-256-hashed, rotated"]
    OWN["👤 Ownership checks<br/>in every service"]
  end

  FE -->|"Authorization: Bearer"| SF
  FE -->|"refresh cookie / body"| RT
  SF --> RL --> OWN
  SF --> TS
  FE <-->|"OAuth 2.0"| GO["🔐 Google"]
  LA -.-|"guards"| TS
  DS -.-|"guards /docs/admin"| OWN

The design in five lines:

Authentication endpoints

Endpoint Method Auth Purpose
/auth/login POST No Email + password login
/auth/register POST No Registration (email verification required before login)
/auth/verify-email GET No Consume the 24-hour verification token
/auth/resend-verification POST No Issue a new verification token and mail it; always the same 200
/auth/google GET No Google OAuth code exchange (web)
/auth/google/mobile POST No Google ID-token verification (mobile)
/auth/oidc/providers GET No The federated providers configured; empty list when none
/auth/oidc/{provider} POST No Federated ID-token sign-in (web)
/auth/oidc/{provider}/mobile POST No Federated ID-token sign-in (mobile)
/auth/oidc/{provider}/link POST Yes Attach a federated identity to the account making the request
/auth/refresh POST No Rotate the refresh token, mint a new JWT
/auth/logout POST No Clear the cookie, revoke the token
/auth/verify GET Yes Session probe; returns "authenticated"
/auth/forgot-password POST No Request a reset e-mail
/auth/reset-password/validate GET No Pre-check a reset token
/auth/reset-password POST No Set the new password

Every unauthenticated auth endpoint shares one rate bucket: 5 requests per 15 minutes per client IP.

How login works

Email + password

sequenceDiagram
  participant U as User
  participant BE as Backend
  participant DB as Database

  U->>BE: POST /auth/login
  BE->>BE: Lockout check (10 fails / 15 min per email)
  BE->>DB: Find user by email
  BE->>BE: BCrypt.matches(input, hash)
  BE->>BE: emailVerified? else 403 EMAIL_NOT_VERIFIED
  BE->>DB: Create refresh token (hash stored)
  BE-->>U: JWT in X-Access-Token + refresh cookie

The order of the checks is the interesting part:

  1. The per-account lockout runs before anything else. Ten failures within the window lock the email for 15 minutes, and the counter tracks unknown emails too, so the lockout itself cannot be used to discover which accounts exist.
  2. A locked account and a wrong password return the same 401 body. No oracle.
  3. Google accounts can never pass this check: their stored password is a literal marker, not a BCrypt hash, so matches always fails.
  4. An unverified account gets a distinct 403 EMAIL_NOT_VERIFIED. That path deliberately trades a little enumeration resistance for a usable "check your inbox" message, and the login screen now hangs a resend button off it, so the trade buys a way out rather than only an explanation.
  5. Success clears the failure counter.

Registration and email verification

Registration stores the user with a 32-byte verification token (24-hour expiry) and sends the confirmation e-mail. The token is single-use: consuming it sets emailVerified and nulls both token columns. Password policy is enforced in the service layer, with the DTOs as backup: at least 12 characters and at least 2 of the 4 character classes.

An honest tradeoff, stated in the code: registration answers "Email already in use" for a taken address, so it is an enumeration oracle by design. The 5-per-15-minutes IP bucket is what keeps that from being farmable at scale.

Google OAuth

Two separate paths, one per platform:

Both paths find-or-create the user by e-mail. Google-created accounts get isGoogleAccount=true, a non-hash password marker, and skip e-mail verification (Google already did it).

Both paths also refuse a matched account that is a password account with an unverified address, returning the same 403 EMAIL_NOT_VERIFIED that login does. Until that guard existed, doLogin was the only reader of emailVerified in the backend, so Google was a way around the gate — mildly, as an accidental cure for a lost verification mail, and seriously as this: anyone can register an address they do not own, and the unverified row they leave behind would swallow the real owner's Google sign-in with no click and no warning. The owner fills that row with their data, and if they ever follow the verification link that arrived when the squatter registered, the flag flips and the squatter's password opens the account. One rule now holds at every door, and it is recoverable rather than merely strict because the resend endpoint landed with it. A verified password account may still link Google freely.

Federated identity, beyond Google

Google was the only external provider, and both of its paths find the account by e-mail. That is safe only while the single issuer actually proves ownership of the address it asserts. Add a second provider on the same rule and every beyou account becomes reachable by whoever operates that issuer: mint a token claiming the address, walk into the account, including accounts belonging to people who never heard of that provider.

So identity moved to the pair the issuer alone controls and cannot reassign, (issuer, subject), held in federated_identities (V29) under a UNIQUE constraint. The claimed address survives as email_at_link, a record of what was said, for support and audit. Nothing authenticates on it, and there is deliberately no repository method that finds a user by it.

FederatedIdentityService.resolve is the only decision point, and every provider funnels through it:

  1. Known (iss, sub) - log in. The only lookup that authenticates anybody.
  2. Address not trusted - refuse with LinkRequired(EMAIL_NOT_TRUSTED). Covers both a provider we do not trust on this point (trustEmailVerified: false, the default) and a token that admitted email_verified: false. Nothing is created, nothing is matched, and the address is not even queried.
  3. Address trusted and already in use - refuse with LinkRequired(ACCOUNT_EXISTS). The account exists; joining a new door to it is a decision taken from inside, through POST /auth/oidc/{provider}/link. This is where the behaviour deliberately differs from Google, which does enter an existing account by address. The reason is blast radius: a user who signed up with a password never agreed that a third party would be able to open their account.
  4. Address trusted and unknown - create the account and the link together.

Both refusals reach the client as a 403 in the standard error envelope: errorKey FEDERATED_LINK_REQUIRED, with the reason and the provider's slug in details. Nearly every other refusal in the API is a 400 (the not-found keys of the calendar and the Ideas board, and the board's two 409s, are the other exceptions, see Ownership), and this one is not, because it is not a failure. The client branches on it and sends the person to sign in their usual way, then link.

trustEmailVerified is per provider and defaults to false. Turning it on is a statement about the operator of that issuer, not about their code: it says a true there means somebody proved they control the address, and that nobody can flip the column by hand.

Token verification (OidcIdTokenVerifier) checks signature against the provider's published JWKS, then issuer, audience, azp and expiry. The algorithm is required to be RS256 by name rather than read from the header, which is what keeps alg: none out. The discovery document is discarded unless its own issuer equals the configured one: without that, redirecting our discovery fetch would let an attacker nominate the JWKS we verify against. Keys are cached per issuer, and an unknown kid triggers at most one refetch per minute, so rotation heals without a deploy and a probe cannot turn each login attempt into an outbound request.

Google is not backfilled in a batch: its subject was never stored, so there is nothing to backfill from. recordSeenIdentity writes the row on the account's next Google sign-in, and failures there are swallowed - a login is not worth failing over bookkeeping, and the address path it falls back to stays correct for Google specifically.

A provider absent from configuration does not exist: /auth/oidc/providers omits it and the login endpoints answer 400 OIDC_PROVIDER_UNKNOWN. That is the off switch, and it needs no code change.

Tokens

Access JWT

Property Value
Algorithm HMAC256 (auth0 java-jwt)
TTL 15 minutes
Claims iss=auth-api, sub=email, exp. Nothing else
Delivery X-Access-Token response header
Consumption Authorization: Bearer request header
Storage Frontend memory only

The claims are deliberately minimal. The role is not in the token; the SecurityFilter re-reads the user row on every request, so a role change or a deleted account takes effect within one request rather than one token lifetime. The cost is a database read per authenticated request, which the Caffeine layer absorbs elsewhere but is a real trade here.

HMAC256 over RSA because only this backend ever signs or verifies: there is no third party to hand a public key to.

Refresh token

The client-held token is {rowId}.{secret}: a UUID naming the database row plus 32 random bytes. The database keeps only the SHA-256 hash of the secret, stored as sha256:<hex>, so a leaked table contains nothing replayable.

Why SHA-256 and not BCrypt. BCrypt is slow on purpose, so a guessable secret like a password costs an attacker real time per guess. These secrets are 256 random bits; there is nothing to guess, so that slowness protected nothing and cost every user time. Refresh ran two BCrypt(12) operations (check the old secret, hash the new one), about 400 ms each on the production box, which held POST /auth/refresh near 850 ms p50. With SHA-256 it is a few milliseconds. The comparison is constant-time.

Rows written before the switch hold BCrypt hashes and are still verified with BCrypt, so the deploy logged nobody out. Each one is replaced by a SHA-256 row on its next rotation, and the last of them expires 15 days after the deploy.

flowchart TD
  CR["🔑 32 random bytes"] --> HASH["🔒 SHA-256 hash"]
  HASH --> DB["💾 Row: id + hash + expiresAt + revokedAt"]
  CR --> OUT["📤 To client: id.secret"]
  OUT --> REF["🔄 POST /auth/refresh"]
  REF --> MATCH["matches(secret, hash)?<br/>expired? revoked?"]
  MATCH --> ROT["Revoke old row, mint new pair"]

The request pipeline

Three custom filters cooperate, and their order matters:

Order Filter Job
1 SecurityFilter (before UsernamePasswordAuthenticationFilter) Bypass list for public paths; otherwise extract the Bearer token, validate signature/expiry/issuer, load the user, populate the SecurityContext. Failures answer 401 with a keyed ApiErrorResponse (JWT_NOT_FOUND, AUTH_HEADER_INVALID, JWT_INVALID, USER_NOT_FOUND)
2 DocsImportSecretFilter (after UsernamePasswordAuthenticationFilter) Constant-time comparison of the X-Docs-Import-Secret header for /docs/admin/import/*; a blank configured secret fails closed with 403
3 RateLimitFilter (plain servlet filter) Runs after the security chain, which is exactly what lets it key buckets by authenticated user

Two details worth knowing before touching this code. First, the public-path list exists twice: once as permitAll matchers in SecurityConfig and once as the SecurityFilter's bypass conditions. They agree today, but they match differently (equals vs startsWith), and drift between them is silent. Second, async dispatches are permitted through the chain because the agent's SSE stream re-dispatches; the compensating invariant is that every protected endpoint must authenticate and ownership-check on the initial dispatch.

Rate limiting

Bucket4j buckets in a Caffeine cache, first matching tier wins:

Tier Endpoints Limit Keyed by
auth login, register, forgot-password, resend-verification, google, google/mobile, POST oidc/{provider} and oidc/{provider}/mobile 5 / 15 min IP
unsubscribe POST /notification/unsubscribe 5 / 15 min IP
agent POST /ai/agent/chats/* 30 / hour user
docs /docs/* (public) 30 / min IP
photo GET /user/photo/* 120 / min IP
onboarding POST /onboarding/suggestions 30 / hour user
account-deletion POST /user/deletion/* 10 / hour user
feedback POST /feedback 10 / hour user
feedback-attachment POST /feedback/*/attachments 20 / hour user
export GET /user/export 5 / hour user
briefing GET /daily-briefing 10 / hour user
notebook-ai POST /notebook/ai/** 60 / hour user
notebook-source POST /notebook/pages//sources/ 20 / hour user
write any other POST/PUT/DELETE 30 / min user
read any other GET 60 / min user

The Daily Briefing sits above the generic read tier for its own reason: the first call of a user's day creates a row and may hold the request for up to eight seconds waiting on the LLM that writes the briefing's prose, and the 60-a-minute read budget is sized for list reads. Ten an hour covers two clients, a reload and a retry, against an answer that is cached on the row for the rest of the day. Its sibling POST /daily-briefing/seen deliberately stays in the generic write tier, being a single-column update with no model behind it. GET /daily-briefing/narrative is matched out of the tier too, by an exact path match on the briefing route: it is the dialog polling for prose that landed after the eight seconds, about ten primary-key reads over a minute, and on ten an hour it would run dry inside that first minute.

The export sits above the generic read tier for a reason worth stating: it is a GET, but it returns the entire account in one response, from habits and routines to the diary, calendar tasks, the Ideas board, the notebook, focus history, morning briefings and every assistant conversation, assembled in memory and serialized in one go. Measured on a synthetic account with a year of heavy use, that is about 2.5 MB of JSON built in under half a second, which is why it stays a plain request and not a background job. Sixty a minute of it is still a way to hold the heap, and nobody taking their data needs a sixth copy inside the hour.

The study notebook has two tiers of its own, both matched ahead of the generic write tier. Every route under /notebook/ai/ is a model call: the study room's chat and outputs, the roadmap draft, suggestions, "explain" and AI flashcards. A study session is a run of short questions against the same sources, and sharing the assistant's 30 an hour would let an evening of studying lock the person out of the assistant, so the notebook gets 60, one question a minute for an hour. Adding a source parses a PDF in memory or fetches a page from the internet, then reads and embeds it in the background, and 20 an hour is a whole reading list in one sitting. The study cycle routes, /notebook/cycles/** and /notebook/sessions/**, have no tier of their own: their reads spend the generic read budget and every other method the generic write one, the editor's POST /notebook/cycles/plan preview included, even though it writes nothing.

Federated sign-in shares the auth bucket because it is the same kind of door: anonymous, and able to create an account. It used to fall through to the write tier, which waves through any request with no user id, so it had no limit at all while the Google routes beside it had five. Only the POST sign-in routes count. GET /auth/oidc/providers is what the login screen reads on every visit and must not spend those five attempts, and /link is authenticated, so the write tier already keys it on the user.

Rejections answer 429 with a Retry-After header; successes carry X-Rate-Limit-Remaining. Both are named in Access-Control-Expose-Headers, without which a browser cannot read either one: neither is on the CORS safelist, so the wait was on the wire and unreachable by the web client.

The client IP comes from the CF-Connecting-IP header, not X-Forwarded-For, and the reason is worth remembering: Cloudflare appends to X-Forwarded-For rather than replacing it, so its leftmost entry is attacker-controlled, and honoring it would hand out a fresh login bucket per request. When the header is absent the filter falls back to the socket address, which behind a tunnel collapses into one shared bucket. That degraded case is precisely why the per-account login lockout exists as an independent second layer.

The whole subsystem is off in the e2e and test profiles, and user-keyed tiers pass unauthenticated requests through untouched.

Password reset

Account deletion

Deleting an account is the one action where a logged-in session is deliberately not enough: the flow demands proof of inbox access.

  1. POST /user/deletion/code mails a six-digit code. BCrypt-hashed at rest, 15-minute TTL, 60-second cooldown between requests, and each new code invalidates the previous ones.
  2. POST /user/deletion/confirm checks, in order: already used, expired, too many attempts (5), then the hash comparison. The attempts counter increments in its own REQUIRES_NEW transaction, because the exception that follows a wrong guess rolls the outer transaction back, and counting inline would have left the cap unreachable.
  3. Spending the code deletes its row inside the same transaction, which doubles as a lock: a racing second confirm blocks, loses, and gets a keyed error.
  4. The deletion itself removes refresh tokens and reset tokens explicitly, the six owned collections through the JPA cascade (categories, habits, tasks, goals, routines, snapshots), chats plus the AI memory, history rows through database-level cascades, and the mood entries with the journal text in them, also by database cascade. Every table added since cascades from users the same way: focus, linked sign-ins, the briefing, the notebook and its study cycles, the nudge mails, the calendar tasks, the notes on the Ideas board. AccountDeletionIntegrationTest and the runbook's test read the tables pointing at users from the live schema, put a row in each, delete, and expect none left, so a new table that forgets its cascade fails there. Files on disk are purged after commit, best-effort: feedback attachments and the profile photo. The photo was missed at first, because the database rows cascade and the bytes on disk do not, so the JPEG outlived the account under a filename that was still the deleted user's id.
  5. The refresh cookie is cleared only after success, so a refused code leaves the session intact.

The journal, and what is done with it

The diary is the one place in the product where somebody writes at length, for themselves, about their own life. It is stored as plain text on a row they own. There is no encryption at rest beyond what the database and the disk provide, and claiming otherwise would be worse than saying so plainly. What surrounds it is where the care went:

The study notebook: notes, sources, and a server that fetches URLs

The notebook holds the second kind of personal writing in the product, study notes, and it adds the one feature that makes the server request a URL somebody typed. Both shaped its security.

Ownership: the authorization model

There is no method-level security in the codebase, on purpose. The model is one rule applied everywhere: every service method receives the authenticated user's id and compares it against the loaded entity's owner, throwing a keyed error on mismatch (CATEGORY_NOT_OWNED, HABIT_NOT_OWNED, TASK_NOT_OWNED, GOAL_NOT_OWNED, ROUTINE_NOT_OWNED, SNAPSHOT_NOT_OWNED, CHAT_NOT_OWNED, FEEDBACK_NOT_OWNED, NOTEBOOK_PAGE_NOT_OWNED, FOCUS_MICRO_TASK_NOT_OWNED, STUDY_CYCLE_NOT_OWNED). Schedules route through the owning routine, which is what closed an early IDOR. The study notebook routes every card, source, output, board node and edge through the page it hangs off, via NotebookOwnership. A study cycle checks its own owner in StudyCycleService and its blocks are reached only through it, while the topics and pages a cycle names get the notebook's page refusals. A logged study session that belongs to somebody else answers STUDY_SESSION_NOT_FOUND, the same as one that does not exist. These all surface as HTTP 400 with an errorKey; clients discriminate on the key, not the status.

The calendar is built the other way round, and it was the first exception to that 400. CalendarTaskRepository.findByIdAndUserId puts the owner inside the lookup instead of checking it afterwards, so a task that belongs to someone else and an id nobody ever used get the same answer on every route (edit, delete, complete, acknowledge): 404 CALENDAR_TASK_NOT_FOUND. A NOT_OWNED key tells the caller the id exists; this answer tells them nothing. The status comes from GlobalExceptionHandler.NOT_FOUND_KEYS, an opt-in list that holds this key and the Ideas board's. The older domains keep answering 400 for their own not-found keys, because the web and mobile clients were written against that and moving them is a coordinated change of its own. A domain designed from now on puts its not-found key on the list from the start. calendar.spec.ts sends all four writes from a second account and expects the 404 on each.

The calendar's reminder mail carries the task's title and note, which are the user's own words. Both are HTML-escaped, the title has control characters flattened before it becomes the subject header, and a failed send logs ids only.

The Ideas board copied the calendar's shape on purpose. QuickNoteRepository.findByIdAndUserId carries the owner, and so do its locking twin (the task conversion reads the note under a row lock) and the batch read behind a layout save, so a note somebody else owns answers 404 QUICK_NOTE_NOT_FOUND on every route. A layout that names one such note is refused whole, and nothing moves. The board also brought the first business-error keys that answer 409 (until then only DATA_CONFLICT did, from a handler of its own for an unexpected constraint violation), through a second opt-in list beside the first, CONFLICT_KEYS: a full board (QUICK_NOTE_LIMIT_REACHED) and a second conversion of the same note (QUICK_NOTE_ALREADY_CONVERTED). The older limit errors, the notebook's and the study cycles', stay 400 for the same reason as the old not-found keys. quick-notes.spec.ts sends six writes from a second account and expects the 404 on each, and checks that account sees an empty board.

A note is free text somebody wrote for themselves, so the quickNotes slice sits on the web persist blacklist next to mood and notebook, and mobile keeps it in memory. The assistant treats it differently from the journal: it can read the notes on the board through getQuickNotes, text included, because picking up an idea from the board is the reason that tool exists. Archived notes are not in that read.

Exactly one role rule exists: /feedback/admin/** requires ADMIN. The ADMIN role is granted only by a manual database update. No seed, no endpoint, no environment variable can mint an admin.

Upload hardening

The two image upload paths (profile photo, feedback attachments) share the same defensive shape:

The third upload path, a study notebook PDF, keeps no file at all; its rules are in the study notebook section above.

Removing a profile photo

A photo is stored in two unrelated places and read in priority order, and that is the whole reason removal needed its own endpoint. An upload writes {upload-dir}/user-photos/{userId}.jpg and never touches the user row; perfilPhoto on the row holds a Google CDN URL, set only at OAuth sign-in. UserMapper looks for the file first and falls back to the column.

DELETE /user/photo clears both. Removing one half always leaves a photo on screen: drop only the file and a Google account falls back to the avatar it had before, clear only the column and the uploaded file goes on being served. The second case is also why PUT /user with an empty photo never worked as a removal, which is what users hit.

The file is unlinked before the column is cleared, and a failed unlink rolls the whole thing back. The alternative order can commit "this account has no photo" over a JPEG that is still on disk and still winning the priority check, which is the one outcome worse than refusing.

The account id comes from the token, never from the path, so the endpoint has nothing of the enumeration surface GET had to be signed to close.

Serving a profile photo

Reading a photo back is the one place here where authorization does not travel in a header. The callers are an <img src> on the web and an <Image uri> on the phone, and neither can send one, so GET /user/photo/{userId} used to answer any caller who could name a user id. Every uploaded face was readable by walking the UUID space.

The URL carries its own proof instead:

/api/v1/user/photo/{userId}?v={mtime}&exp={epoch}&sig={HMAC-SHA256(userId|exp)}

The cost is that the URL works for whoever holds it until exp passes, including anyone it gets forwarded to. It exposes a single image the sender could already see.

AI agent guardrails

The agent chat can call real tools, so its authority model matters:

Headers, CORS, and boot guards

Headers set by the backend on every response:

CORS: allowed origin patterns from one environment variable, comma-separated (production lists the web app and the docs site), credentials enabled, and three exposed headers: X-Access-Token, Retry-After, and X-Rate-Limit-Remaining. Preflights carry Access-Control-Max-Age: 3600; without it browsers kept a preflight for about five seconds, and roughly one request in three in production was an extra OPTIONS round-trip. Dev runs a wildcard; production refuses one (next paragraph).

Boot-time validators, the "refuse to start" layer:

Guard Refuses boot when
SecurityConfigValidator (prod only) CORS pattern is *, JWT secret shorter than 32 chars, cookie.secure false, or either e2e escape hatch (deletion-code exposure, auto-verified e-mail) is enabled
SchemaOwnershipGuard Flyway is on but Hibernate ddl-auto is anything other than validate or none
E2eSafetyCheck (e2e profile) The datasource URL does not look like a test database

Operational posture: the actuator lives on its own management port, separate from the API, which in production only Prometheus and the uptime probes are allowed to reach (a network policy, not the bind address, enforces it), with a fixed endpoint list (the env override is deliberately dropped there); Swagger is off in production; AOP logging records argument counts, never values; the container runs as a non-root user; CI runs CodeQL and a weekly OWASP dependency check.

Security assessment

What is done well

What could be improved

Area Current state Honest note
2FA / MFA Not implemented The deletion flow's e-mail code is the only second factor in the product
Audit logging Not implemented Failed logins, resets, and refreshes leave no dedicated trail
Refresh token binding Not bound to device or IP Rotation limits the damage window but a stolen token works anywhere until then
Google account linking Find-or-create by e-mail, verified accounts only A VERIFIED password account is still logged in by a matching Google identity with no explicit linking step. The unverified case, which was the dangerous one, is now refused
Registration enumeration "Email already in use" by design Rate-limited, and a usability tradeoff, but still an oracle
Reset cooldown nuance 400 inside the cooldown for real accounts A patient prober can distinguish known addresses on a second request
verify-email throttling Unthrottled Unauthenticated GET that falls through the user-keyed tiers; token entropy is the only guard. Its sibling POST /auth/resend-verification IS in the auth tier
Verification token at rest Plaintext column on the users row The reset token is stored as a BCrypt hash; this one is readable straight out of a database dump
Docs import secret Compared constant-time, fails closed when blank Nothing validates its length or entropy at boot
Prompt injection Instruction-level defense only No programmatic filtering of user text before it reaches the model
CSP regression test Header existence is asserted, value is not A silent CSP weakening would pass the suite
Link sources and DNS rebinding Every resolved address checked, redirects re-checked by hand The connection is not pinned to the checked address, so a name that changes between the check and the connect gets through. The useful internal targets answer nothing a text extractor can use

Threat model summary

Threat Mitigated? How
Password theft from a DB leak Yes BCrypt cost 12; reset/deletion secrets BCrypt-hashed, refresh secrets SHA-256-hashed
XSS stealing tokens Mostly JWT in memory, refresh in HttpOnly cookie, CSP on API responses
CSRF Yes Stateless bearer auth; cookie read only by refresh/logout; SameSite Strict in prod
Brute-force login Yes 5/15min per IP plus 10-failure account lockout
User enumeration Mostly Login and reset are silent; registration and the reset cooldown are the documented exceptions
Token replay after rotation Yes Old refresh tokens are revoked transactionally
IDOR Yes Ownership check in every service, keyed errors, schedule routed through its routine, calendar and Ideas board lookups scoped to the owner
Decompression bombs Yes Header-level pixel cap before decode
Confused-deputy AI tools Yes Server-built ToolContext; tools inherit caller authority only
Session fixation Yes No sessions exist
SSRF through notebook link sources Mostly Public addresses only, redirects checked per hop, bodies capped; DNS rebinding is the documented gap