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.
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:
X-Access-Token response header.| 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.
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:
matches always fails.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.
Two separate paths, one per platform:
GET /auth/google?code=): the backend exchanges the authorization code with Google server-side (client secret never leaves the server) and reads the profile with the resulting access token. The web client generates and verifies its own state value before handing the code over.POST /auth/google/mobile): the native app sends a Google ID token, which the backend verifies with Google's official verifier: signature against Google's published keys, issuer, expiry, and an audience allowlist. The token is additionally rejected unless Google itself reports the e-mail as verified.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.
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:
(iss, sub) - log in. The only lookup that authenticates anybody.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.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.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.
| 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.
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"]
Secure and SameSite=Strict in production (Lax in dev), path /, 15-day maxAge.X-Client: mobile, the backend skips the cookie entirely and returns the refresh token in the response body; later refreshes send it back in an X-Refresh-Token header. Cookies are a poor fit for native HTTP stacks, so mobile owns its storage.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.
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.
{rowId}.{secret}, BCrypt-hashed at rest, single-use, 15-minute TTL, and requesting a new one invalidates all previous tokens.Deleting an account is the one action where a logged-in session is deliberately not enough: the flow demands proof of inbox access.
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.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.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.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:
mood slice is on the web persist blacklist, and
mobile redux is in-memory only. A closed tab leaves nothing behind. A test reads the blacklist
out of the source file, because the failure is silent: everything works, and the diary simply
sits in localStorage.ServiceMethodsLogging records argument counts, never values.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.
notebook slice is on the web persist blacklist next to mood, and mobile redux is in-memory. The studyCycles slice joins it on the blacklist. The export carries every page as plain text, along with the boards, every flashcard review, what the study room produced, and the study cycles with their logged sessions. Deletion takes all ten notebook tables and the seven study cycle tables through users cascades.NotebookOwnership.page runs before any notebook path does anything, cards, sources, outputs and boards included, and so does a focus cycle that names a page. It throws NOTEBOOK_PAGE_NOT_FOUND or NOTEBOOK_PAGE_NOT_OWNED, both as 400. A path that skipped it would be an IDOR on the most personal writing the product stores, so it is one method rather than a pattern each service repeats. A roadmap draft is not a page yet, so RoadmapDraftService checks its own owner and answers NOTEBOOK_DRAFT_NOT_FOUND or NOTEBOOK_DRAFT_NOT_OWNED; creating a topic with somebody else's draftId deletes nothing. notebook-rules.spec.ts asserts both refusals on the wire.%PDF- magic number, parsed in memory by PDFBox, and only its text survives. There is no file on disk, so nothing can be served back byte-for-byte and nothing is left behind when an account goes. The title is the file name with any path stripped, and it never touches the filesystem anyway.LinkFetcher is the only place the server fetches a user's URL. It accepts http and https only, with a host and no user-info. Every address the host resolves to must be public: loopback, private ranges, link-local (the cloud metadata address among them), carrier-grade NAT, multicast, the unspecified address and IPv6 unique-local are refused, as is an IPv6 address wrapping one of them. The HTTP client follows no redirects. Each hop is checked by hand, three at most, so a public page cannot bounce the request inward. Bodies are capped while they are read (3 MB of HTML, 15 MB of PDF), with a 15-second timeout. The check also runs at request time, so a refused URL answers NOTEBOOK_SOURCE_URL_REFUSED and never becomes a row. The e2e suite points a source at the management port and expects that refusal.LinkFetcher.resolve under the same rules as a link the person pastes: public addresses only, every redirect hop checked, and only enough of the page read for its title. So a search result cannot make the server request something a pasted link could not.java.net.http cannot do without dropping TLS hostname checks. The window is the gap between two resolutions inside one request. The targets that matter (the management port, cloud metadata) answer nothing useful to a GET that must come back as HTML or PDF, and the response is never shown raw, only as extracted text.notebook-ai bucket, sources spend notebook-source, and a page holds 20 sources of its own.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.
The two image upload paths (profile photo, feedback attachments) share the same defensive shape:
GlobalExceptionHandler picks the error key from the path that was uploading: FEEDBACK_ATTACHMENT_TOO_LARGE, NOTEBOOK_SOURCE_TOO_LARGE or PHOTO_UPLOAD_TOO_LARGE.The third upload path, a study notebook PDF, keeps no file at all; its rules are in the study notebook section above.
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.
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)}
HMAC(TOKEN_SECRET, "beyou-photo-url-v1"), so there is no second secret to deploy, and a photo signature is useless as a token anywhere else.UserMapper mints the URL while answering GET /user, and nothing else mints one. Login does not: it maps the user without a photo version, so a client that wants the signed URL has to ask for the profile.exp is covered by the signature, so the deadline cannot be extended by editing the query string. The default TTL is 12 hours (PHOTO_URL_TTL_MINUTES), which keeps an avatar rendering in a tab left open overnight while a URL captured in a proxy log stops working the same day.MessageDigest.isEqual, so a partial guess leaks nothing about how much of it was right.Cache-Control is private, because a shared cache would go on serving the bytes after the signature expired.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.
The agent chat can call real tools, so its authority model matters:
Headers set by the backend on every response:
default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' https: data:; connect-src 'self' https://accounts.google.com https://www.googleapis.com; font-src 'self' https: data:; frame-ancestors 'none'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.
| 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 | 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 |