Nora handles student data, so security is enforced at the database and network edges — not just in the UI. This document describes the model, the controls in place, and how to report an issue.
- Authentication is handled by Supabase Auth. Sessions are carried in HTTP-only cookies.
proxy.tsruns at the edge on every matched request: it refreshes the session and redirects unauthenticated users away from/approutes before any page renders.- The protected layout independently re-resolves the user server-side (
getCurrentUser()), so a page never renders with a stale or absent session.
Every user-owned table in Postgres enforces Row-Level Security with policies keyed on auth.uid(). Application code cannot read or write another user's rows even if a query is malformed — the database rejects it. RLS is treated as the last line of defense, independent of the client and the Server Actions layer.
The canonical pattern on every user-owned table (cards, topics, subjects, feynman_sessions, reviews, profiles, …):
alter table cards enable row level security;
create policy "owner reads own cards"
on cards for select
using (auth.uid() = user_id);
create policy "owner writes own cards"
on cards for insert
with check (auth.uid() = user_id);
create policy "owner updates own cards"
on cards for update
using (auth.uid() = user_id)
with check (auth.uid() = user_id);Two properties make this robust:
USINGgates reads and the pre-image of writes;WITH CHECKgates the post-image. Both are present on mutating policies so a user can neither read another row nor rewrite a row into someone else's ownership.- Default-deny. With RLS enabled and no matching policy, Postgres denies. New tables are locked until an explicit policy opens them to their owner — the safe default, not the permissive one.
This is not taken on faith. The claim "an anonymous or cross-user client cannot read these rows" is verified directly against the database by the backend test suite (see Security testing below), not merely assumed because the UI looks locked.
A few operations run as SECURITY DEFINER functions (they bypass RLS by design — e.g. awarding rewards, advancing shared party quests). These are the highest-risk surface in a Postgres app, so each one:
- Sets
search_path = publicto prevent search-path hijacking. - Asserts the caller's identity. Reward functions reject any call whose target user is not
auth.uid(); the trusted server (service_role, whereauth.uid()is null) is the only path allowed to perform legitimate cross-user writes (e.g. rewarding every member when a group quest completes). - Locks down execution.
EXECUTEis revoked fromPUBLICand fromanon, and granted only toauthenticatedandservice_role.
This closes an IDOR class where the public API could otherwise be used to inflate arbitrary users' XP/coins or advance any group's quest. See migration supabase/migrations/020_secure_reward_rpcs.sql; migration 005 is the reference pattern for party helpers.
Nora fetches external content (academic PDFs, university calendars, web results). All outbound fetches pass through an SSRF guard (src/lib/ssrf.ts) before the request leaves the server. The guard:
- Rejects non-
http(s)schemes outright (file:,ftp:,gopher:,data:— classic SSRF pivots). - Resolves the host and blocks private ranges — loopback (
127.0.0.0/8,::1), private (10/8,172.16/12,192.168/16), link-local (169.254/16), and the cloud metadata endpoint (169.254.169.254), which is the single highest-value SSRF target on a cloud host. - Applies a per-request timeout so a slow or hanging target can't tie up a Server Action.
- Constrains university auto-discovery to an official-domain allowlist, so calendar discovery can't be redirected to an arbitrary internal host.
Fetched content is always treated as data, never as instructions. A retrieved page, PDF, or search result can never act as a prompt to the model — the AI-safety layer (below) keeps retrieved text on the "data" side of the boundary.
- Provider keys (Groq, OpenRouter, OpenAI, Tavily, Firecrawl, etc.) and the Supabase service-role key are server-only. They are read from environment variables inside Server Actions and never sent to the browser.
- Only
NEXT_PUBLIC_*values (the Supabase URL and the anon key) are exposed to the client — the anon key is public by design and is constrained by RLS. .env.localis git-ignored. Never commit real keys. If a key is ever exposed, rotate it in the provider dashboard.
- Grounded generation. AI answers are constrained to retrieved sources; unverified output is labelled and insufficient data is declared rather than fabricated.
- Rate limiting. AI-backed actions and reward writes are rate-limited per user, per action type, to contain abuse and cost (
src/lib/rate-limit.ts; the atomic reward RPC is capped via therewardpreset). Honest caveat: the limiter is an in-memory sliding window, so it is best-effort per server instance — on a serverless / multi-instance deployment it neither shares state across instances nor survives a cold start. It raises the cost of scripted abuse but is not a hard distributed quota; the database-side identity guard (above) is the authoritative control, and a shared store (e.g. Redis) would be required for a strict global limit. - File uploads are size-capped (PDFs to 20 MB local / 50 MB by URL) and parsed defensively.
Security controls that can't be seen in the UI are verified directly, as part of the TestSprite loop:
- RLS is proven, not assumed. Backend tests (
testsprite test create --type backend) run as an unauthorized client and confirm the database rejects the request:- anon cannot read another user's
cards, - anon/cross-user cannot insert into
topics, feynman_sessionsare unreadable without the owner's session. A page looks identical whether RLS is airtight or wide open — the only way to know is to hit the database as an attacker would and confirm the denial. The browser runner can't do that; the backend runner can, so both are used.
- anon cannot read another user's
- Schema invariants are checked — the tables and the columns the reward/scheduling logic depends on (
stability,difficulty,due,state,xp,coins) exist and are typed as expected. SECURITY DEFINERfunctions are covered by the reward round-trip tests — a user can earn XP through the legitimate path, but the identity assertion blocks awarding XP to an arbitrary target user. This guard is also pinned by a dedicated SQL test (supabase/tests/013_reward_rpc_guard.test.sql) that simulates an authenticated attacker (via the request JWT claim) calling the reward RPC against a foreign user id, and asserts the call is rejected (42501) with the victim's balance unchanged — while the legitimate self-reward path still succeeds.
See LOOP.md → Backend Testing on TestSprite for the per-test table.
| Threat | Control | Where |
|---|---|---|
Unauthenticated access to /app |
Edge session gate + server-side re-resolve | proxy.ts, protected layout.tsx |
| One user reading/modifying another's data | RLS USING + WITH CHECK on every owned table |
Postgres policies (migrations) |
| IDOR on rewards / group quests (inflate any user's XP) | Identity-asserting SECURITY DEFINER RPCs, EXECUTE revoked from anon/PUBLIC |
migration 020, party helpers in 005 |
| SSRF via user-supplied URLs | Scheme + private-range + metadata blocking, allowlist, timeout | src/lib/ssrf.ts |
| Prompt injection from retrieved content | Retrieved text treated as data; grounded generation; unverified output labelled | AI-safety layer |
| Secret leakage to the browser | Provider + service-role keys are server-only; only NEXT_PUBLIC_* exposed |
Server Actions, env handling |
| Abuse / cost blowout on AI actions | Per-action-type rate limiting | AI action wrappers |
| Malicious file upload | Size caps (20 MB local / 50 MB by URL), defensive parsing | upload handlers |
If you find a security issue, please do not open a public issue. Email the maintainer at the address in the app footer / NEXT_PUBLIC_SUPPORT_EMAIL with:
- a description of the issue and its impact,
- steps to reproduce, and
- any relevant logs or proof-of-concept.
You'll get an acknowledgement, and we'll work with you on a fix and disclosure timeline. Nora is a non-commercial educational project maintained by a small team, so please allow reasonable time to respond.