Security at Vortex: tokens, trust boundaries and the write path
Where every secret lives, which way requests are allowed to flow, and the ordered checks each write passes before it touches the dataset.
By Vortex team · 3 min read
A learning platform holds less sensitive data than a bank, but it still holds people's accounts, their progress and anything they write. This note describes how Vortex protects them, concretely enough that you could check the code against it.
Secrets stay on the server
Vortex uses a handful of credentials: a Sanity read token, a Sanity write token, the Clerk secret key, keys for the language model provider, and a YouTube Data API key. All of them are read only in server modules. The modules that use them import "server-only", which makes the build fail if client code ever imports them, so a leak is a compile error rather than a production incident.
Only two keys ever reach the browser, and both are public by design: Clerk's publishable key and PostHog's project key.
The browser holds only public keys. Every secret stays on the server, and the private dataset is reachable only from there.
A private dataset
The Sanity dataset is private. Nothing, not even published course content, can be read without a token. Pages fetch on the server, so a visitor receives rendered HTML and the fields a page chose to show, never raw documents.
Authentication
Sign-in is Clerk. Browsing, search and every lesson page are public. Private surfaces, meaning My Learning, plans and every write route, are gated by Clerk's middleware, and each route handler checks the session again itself. A route never takes a user id from the request body; the learner is always whoever the session says is signed in.
The write path
Every write goes through a server route, and every route applies its checks in order, rejecting as early as it can. The Ideas board, where learners post and vote on feature ideas, shows the full sequence.
Each gate rejects with its own status code before any work is done.
Same origin and JSON only. A request whose Origin header does not match the site is refused (403), and anything that is not application/json is refused (415). A plain HTML form on another site cannot produce either.
A session. No Clerk session, no write (401).
A strict schema. Zod validates the body and rejects unknown fields, so a client cannot smuggle in a status, an owner or a moderation flag (400). Bodies are size-capped before parsing.
A rate limit. Limits are counted from the dataset rather than from memory, so they hold across serverless instances: three ideas a day per learner, five plans a day (429).
Server-set fields. The server assigns ids, timestamps, ownership and status. A new idea arrives as pending and is invisible to others until someone on the team approves it.
The write itself, with the server token.
Concurrency: compare-and-set
Progress is written often and sometimes in parallel, for example when two tabs save a video position at once. Progress writes use compare-and-set: read the document, compute the change, and commit only if the revision is still the one that was read. On a conflict the route re-reads and retries with a short randomized pause. Two saves never silently overwrite each other with stale state.
Grades are computed, never accepted
When you submit a quiz, the browser sends your answers, not your score. The server grades them against the stored quiz. A stored grade can never be one your answers did not earn.
Untrusted text
Anything a learner writes, such as an idea's title and description, is stored and rendered as plain text. It is never parsed as HTML or markdown and never injected into the page, and invisible control characters are stripped on the way in. Rich text written by the team in the Studio is rendered through Portable Text components that only allow http and https links.
Headers and embeds
Every response carries a baseline of security headers: nosniff, a strict-origin referrer policy, a same-origin frame policy, and a permissions policy that turns off the camera, microphone and geolocation. Video embeds are built from a parsed video id, never from a stored URL, and YouTube videos load from the privacy-enhanced youtube-nocookie.com domain.
The search model is read-only
The language model behind search holds no write token and has no mutation tool. The Context server it talks to is scoped by a filter to content types, so learner state such as progress, plans and ideas is outside what it can see.