CatalyzeUp
sondely/migration

Sign-in, Sessions and API Keys

How people, respondents and programs prove who they are in Sondely today, and how the TypeScript app does it. Read from the code on 5 November 2026: sondely/backend/app/api/auth/routes.py, backend/app/utils/auth.py, backend/app/utils/permissions.py, backend/app/core/security.py, backend/app/core/redis.py, backend/app/middleware/rate_limit.py, backend/app/api/api_keys/routes.py, backend/app/main.py, and the frontend in frontend/src. The target copies TourCockpit's src/lib/auth/* and src/lib/api/auth.ts at commit f952d0eea. Every route named here is in the API Route Map by its number (A1 to A17, K1 to K3); the tables are on Data Model.

How a request is identified today

Every protected route depends on get_current_user in backend/app/utils/auth.py. It reads one bearer token and tries three things in order. Nothing else is checked: no scope, no organization, no revocation list.

The organization on an API key is stored on the user object as _api_key_org_id (backend/app/utils/auth.py, line 68) and is read nowhere else in the backend. The JWT's scope claim (set to respondent by auto-enroll) is read nowhere either.

Sign-in methods today

Method Routes Code What it stores What the caller gets Notes
Password, register A1 POST /api/v1/auth/register register, backend/app/api/auth/routes.py line 122 User with a bcrypt hash (passlib CryptContext(schemes=["bcrypt"]), backend/app/core/security.py); a verification token JWT and Redis session token, signed in at once Minimum length 6 (UserCreate, backend/app/models/user.py). "Email already registered" tells anyone which emails have accounts. Signs the user in before the email is proven
Password, sign-in A2 POST /api/v1/auth/login login, line 169 Redis session JWT and session token No rate limit. An unknown email returns before any bcrypt work, so the response time tells which emails exist
Email verification A3 GET /api/v1/auth/verify-email, A4 resend verify_email line 184, resend_verification line 203 email_verifications: user id, token (secrets.token_hex(32), stored in plain text), 24 hours A redirect to /auth/signin?verified=true Nothing in the backend requires email_verified: an unverified account can do everything a verified one can
Password reset A5 POST /forgot-password, A6 POST /reset-password lines 237, 263 password_resets: token in plain text, 1 hour, used_at A message A5 answers the same text whether the email exists. A6 does not end existing sessions
Passwordless code A7 POST /request-otp, A8 POST /verify-otp lines 308, 340 otp_codes: 6 digits, bcrypt hash, 10 minutes, 5 attempts per code, 5 codes per email per hour JWT and session token; a new email becomes an account, an email-only account is promoted and marked verified The one path that proves the email before signing in
Google A9 GET /auth/google, A10 GET /auth/google/callback lines 464, 501 oauth_states (15 minutes, a validated return_origin); the user, linked by google_id, else by email A redirect to {origin}/auth/google/callback?access_token=...&session_token=... Links an existing account by email, including one whose password was set by someone else and never verified. Both tokens travel in the URL
Apple A11 GET /auth/apple, A12 POST /auth/apple/callback lines 621, 651 oauth_states; the user, linked by apple_id, else by email A 303 redirect with both tokens in the URL Reads the id_token with get_unverified_claims (line 688): the signature, audience and expiry are not checked. The token comes straight from Apple's token endpoint over TLS with Sondely's client secret, so this is a missing check, not an open door
Auto-enroll A13 POST /auth/auto-enroll auto_enroll, line 753 A new email-only user and a RESPONDENT membership in the org named in the body A 4-hour JWT with scope: respondent. For an email that already has an account, a token for that account survey_id in the body is never read. See the holes below
Upgrade A14 POST /auth/upgrade upgrade_account, line 819 The password on an email-only account JWT and session token Needs no credential: the email comes from the body. "User not found" (404) tells anyone which emails exist
Sign-out A15 POST /auth/logout logout, line 854 Nothing A message Revokes nothing. The JWT stays valid until it expires and the Redis session for 24 hours
Who am I A16 GET /auth/me, A17 GET /auth/session lines 860, 889 UserMeResponse with roles and is_admin is_admin carries the rule described under open decisions
Tenant enrollment inside A1, A8, A10, A12 _ensure_tenant_enrollment, line 94 A RESPONDENT membership in the org whose custom_domain matches, and assignments to its published surveys For A1 and A8 the host is the request's Host header, which a direct API call sets freely; for A10 and A12 it is the validated return_origin

Tokens and sessions today

Credential Format and lifetime Where it lives Revocation
Access token HS256 JWT, sub = user id, 30 minutes (ACCESS_TOKEN_EXPIRE_MINUTES, backend/app/core/config.py). Signed with SECRET_KEY from Key Vault in the dev and prod container apps (catalyzeup-devops/azure/main.tf); the code default is a placeholder used only when the variable is missing Browser localStorage (access_token), sent by both clients (frontend/src/api/client.ts, frontend/src/utils/api.ts) None
Session token secrets.token_urlsafe(32) as a Redis key, 24 hours (_create_tokens_and_session, line 57; backend/app/core/redis.py) Browser localStorage (session_token), never sent by the frontend (inferred from a search of frontend/src: the token is stored, not read) None from the app. Without Redis, connect logs a warning and sessions are silently not stored
Respondent token The same JWT with scope: respondent, 4 hours In memory in the take page (setSessionOnlyToken, frontend/src/api/client.ts) None
API key ip_live_ plus 32 hex characters; first 12 characters stored as key_prefix and looked up with find_one, full key bcrypt-hashed; expires_at exists but K1 never sets it (backend/app/api/api_keys/routes.py) The integrator revoked_at (K3)
REFRESH_TOKEN_EXPIRE_MINUTES Defined in config.py, used nowhere

Because the frontend sends only the 30-minute JWT and every 401 clears localStorage and redirects to /auth/signin, a person is signed out mid-task after 30 minutes regardless of the 24-hour session (inferred from the clients' 401 handlers, not observed).

The key prefix carries only 4 random hex characters (65,536 values) and the lookup is find_one by prefix: two live keys that share a prefix cannot both work, because only one document comes back to be verified (inferred from code, not run).

Target design

Credentials

Credential Who holds it How it is presented Resolves to
Session A person in the browser At cutover: Authorization: Bearer <token>, the same contract the Vite app uses. When the screens move to Next: an httpOnly cookie The user; the role is read per organization on each request
API key A program Authorization: Bearer <key> One organization and one scope; the actor in the audit is apikey:<id>, never the person who created it
Respondent grant A person taking one survey without an account Authorization: Bearer <grant> (in memory, as today) One survey in one organization, until it expires
Test seam The e2e and contract suites POST /api/v1/test/sign-in A real session, only when ENVIRONMENT=test

All three stores hold a SHA-256 hash of the token and are looked up by that hash, as TourCockpit does with hashToken (src/lib/auth/tokens.ts) and hashApiKey (src/lib/api/auth.ts). The guard for each route comes from its registry Rbac (public, self, grant, member, admin, owner, test), defined on the API Route Map.

Roles

Today After
Role enum in backend/app/models/enums.py has ADMIN and RESPONDENT; OWNER is a bare string used across the routes Prisma enum MemberRole { OWNER ADMIN RESPONDENT }, stored lower case in the API: owner, admin, respondent
The survey creator passes every survey check without a role (get_survey_with_org_access), even after leaving the organization, and an admin creator can delete all responses, an owner-only action Roles only. The creator is recorded, not trusted (an open decision below)
Respondents read the member list and pending invitations (O9, O14) Admin and owner only
An admin may remove another admin (O12); an owner may be demoted, leaving none (O13) Removing an admin is owner-only; the last owner cannot be removed or demoted (LAST_OWNER)

Passwords

  • Argon2id through @node-rs/argon2, with TourCockpit's parameters (19 MiB, 2 passes, 1 lane), copied as is from src/lib/auth/password.ts.
  • bcrypt accepted once, then rehashed. TourCockpit's verifyPassword only reads argon2 digests. Sondely's copy adds one branch: a digest starting $2a$, $2b$ or $2y$ is checked with bcryptjs (pure JavaScript, so the musl image needs no build tools); on success the password is hashed with argon2id and stored in the same transaction. needsRehash already returns true for any digest that is not argon2id. A test signs in with a real passlib digest from a Python fixture and asserts the stored digest starts $argon2id$ afterwards.
  • The unknown-email branch spends the same work as a real check (verifyAgainstDummy), which closes today's timing oracle on A2.
  • Policy from src/lib/auth/password-policy.ts: minimum 6 (today's rule, kept so no existing password is refused), maximum 256, the common-password and own-email checks.

Sessions in Postgres

  • TourCockpit's Session model: tokenHash, expiresAt, lastSeenAt, sliding expiry with an absolute ceiling (src/lib/auth/session.ts, src/lib/auth/config.ts). TourCockpit's windows are 90 days sliding and 180 absolute; Sondely's are set in its own config (proposed: 30 days sliding, 90 absolute, since owners hold respondent data).
  • Bearer contract at cutover. A1, A2, A8 and A14 keep returning access_token, session_token and token_type: bearer. Both fields carry the same opaque session token. The frontend never decodes the token (no JWT parsing anywhere in frontend/src), so this changes nothing on screen and ends the 30-minute sign-outs. JWTs and Redis go away; everyone signs in again once at cutover, which falls inside the write freeze.
  • A15 deletes the session row. A6 (reset) and any password change call destroyAllSessions.
  • Then the httpOnly cookie, when the screens move to Next: a cookie named for Sondely in place of tp_session, httpOnly, Secure in production, SameSite=Lax, host-only. 17 files in frontend/src read access_token from localStorage today; each stops when its screen moves. The bearer path for browsers is removed when the last screen has moved; bearer stays for API keys and grants.
  • Custom domains. A host-only cookie means a person signs in on the domain they use. Google and Apple keep one registered redirect URI on the canonical host, so the callback hands back to the tenant domain with a short-lived signed payload (src/lib/auth/signed-payload.ts, the way src/lib/auth/google-return.ts does it) that the tenant host exchanges for its own cookie. Tokens never travel in a URL again.

TourCockpit's MagicLinkToken (tokenHash, kind with no default, expiresAt, consumedAt) holds every single-use secret:

Kind Today After
SIGN_IN_CODE (new) otp_codes with a bcrypt hash 6 digits stored as an HMAC with a server secret (a bare SHA-256 of a 6-digit code is reversed offline in moments); plus email and attempts. 10 minutes, 5 attempts, codes per email and per IP limited
VERIFY_EMAIL (new) email_verifications, plain text Hashed, 24 hours
RESET password_resets, plain text, 1 hour Hashed, TourCockpit's RESET_LINK_TTL_MS (30 minutes)
SET_PASSWORD not used Kept from TourCockpit for invitations that set a password

A3 stays a GET for the link in old emails, and consumes the token; A8 keeps today's behaviour (a new email becomes an account, an email-only account is promoted).

Google and Apple

  • Google is ported from src/lib/auth/google.ts and src/app/api/auth/google/callback/route.ts, keeping Sondely's return_to validation (canonical hosts plus organization custom domains) and the rate limit TourCockpit puts on the callback.
  • Where Google sign-in starts and returns (fixed 9 November 2026, after "Continue with Google" answered 422 on platform.sondely.com). Behind the Container Apps ingress the app sees its own address, http://localhost:8000, so the address the browser used is read from the Host header, with the scheme from X-Forwarded-Proto when TRUST_PROXY is true. A start on any host other than APP_BASE_URL's (a custom domain, the *.azurecontainerapps.io address) is redirected to the canonical host first, because Google returns there and the binding cookie must be set there; hosts are compared, never full origins, so a missing forwarded scheme cannot loop. Without an explicit return_to, sign-in returns to the app: no custom-domain page exchanges the handoff yet, so returning to a custom domain would leave the person signed out. A handoff exchanged without an Origin header is bound to the public host. Tests: tests/integration/review-oauth.test.ts. The Google client lists https://platform.sondely.com/api/v1/auth/google/callback and the dev equivalent as redirect URIs (added by Chip on 9 November 2026).
  • Apple is written new (TourCockpit has none): a form_post callback, the client secret as an ES256 JWT as today, and the id_token verified against Apple's published keys (issuer, audience, expiry, nonce) before any claim is read. The name arrives only on the first authorization and is saved then. Relay addresses (privaterelay.appleid.com) are treated as the account's email.
  • Linking by email only when the provider says the email is verified. When a provider or a code proves an email whose account was never verified, any password on that account is cleared and its sessions are ended, so someone who registered with another person's email before them loses that access.

API keys

Today After
Bound to Nothing: the key acts as its creator in every org the creator belongs to One organization; a key on another org's resource gets 404 NOT_FOUND
Scope Whatever the creator may do read (GET only), draft (create and edit drafts; never publish, delete, approve a translation or send email), full
Storage 12-character prefix plus bcrypt SHA-256 of the whole key, unique, looked up directly (TourCockpit ApiKey.hashedKey)
Expiry Column exists, never set Optional at creation (K1)
Who manages keys OWNER/ADMIN, and any key whose creator is OWNER/ADMIN Owner or admin, signed in. A key never creates, lists or revokes keys
Audit last_used_at, request_count lastUsedAt, and every write in Audit with actor apikey:<id>
Rate limit 60 a minute, in memory, per replica (the container apps run 1 to 3 replicas, catalyzeup-devops/azure/main.tf) Per key, shared across replicas

The new prefix is a decision below; the scope check lives in the registry guard, so a route with no key scope refuses every key.

Respondent grants

A grant replaces the auto-enroll token. Model RespondentGrant: id, tokenHash, organizationId, surveyId, email, userId (when the email has an email-only user), expiresAt (4 hours, as today), revokedAt, createdAt.

Rule Why
A13 mints a grant only for an email with no account or an email-only one, and only for a published survey in that organization Today it hands any account's token to anyone who knows the email
An email with a real account gets SIGN_IN_REQUIRED; the take page sends a code (A7) and signs in (A8) The existing passwordless path already proves the email
Accepted only by S10, S11, S12, SE1 to SE6, SE8, SE9 and A14, for its own survey Everything else answers GRANT_OUT_OF_SCOPE
A grant reads only what it wrote: S12 and the session reads return rows stamped with the grant id, never rows matched by email Today my-responses matches by the email typed, so typing someone's email shows their history
A14 (upgrade) needs the grant and a code sent to the grant's email; the email comes from the grant, never the body A password is set only on an email that was proven
A response submitted under a grant carries a claim token returned by S10; S11 requires it Today any signed-in person can claim any unclaimed response by id

The take page already holds the token in memory and sends it on the session calls; A14 is the one call that must start sending it (frontend/src/pages/SurveyTakePage.tsx, line 113 posts without a bearer today).

Rate limiting

TourCockpit's limiter (src/lib/auth/rate-limit.ts) is a fixed window in memory, written for one replica. Sondely runs up to three, so its copy keeps the same interface over a shared store: a RateLimitBucket table with one upsert per hit, or an in-memory limiter behind a single-replica setting (a decision below).

Route Limit (proposed, TourCockpit's numbers where it has the same route)
A2 sign-in 20 an hour per IP, 5 per 15 minutes per email
A5 forgot password 20 an hour per IP, 3 an hour per email
A7 code request 20 an hour per IP, 5 an hour per email (today's rule)
A8 code check 5 attempts per code (today's rule), 30 an hour per IP
A1 register, A13 auto-enroll 20 an hour per IP
A10, A12 callbacks 30 an hour per IP
S10 respond 30 an hour per IP per survey
API keys 60 a minute per key (today's number)

Every refusal is 429 RATE_LIMITED with Retry-After, class retry.

CORS

Today backend/app/main.py passes allow_origins=["http://localhost:6291", "http://127.0.0.1:6291", "*"] with allow_credentials=True, allow_headers=["*"] and expose_headers=["*", ...]. Starlette then echoes the caller's origin back with credentials allowed. The cors_origins setting in config.py is not used. Because the token sits in localStorage rather than a cookie, a hostile page cannot ride the session today; it would the day the cookie arrives.

After: the app is one origin per host (Next serves the screens and the API, custom domains included), so /api/v1 sends no CORS headers to browsers by default. An explicit allowlist from configuration covers anything that must call from another origin; * is never combined with credentials. API keys are server-to-server and need no CORS.

Security holes today and how the new design closes each

The first four are the ones named in Sondely as an Agent, step 0, which Chip has accepted for now. The rest were found reading the code for this page and have no decision yet.

# Hole Where What it allows Closed by Proven by
1 Auto-enroll returns a token for any existing account, and the respondent scope is never checked A13, auto_enroll line 753; get_current_user Anyone who knows an owner's email and the org id (public on every survey page) acts as that owner for 4 hours Grants are their own table and credential; an existing account gets SIGN_IN_REQUIRED; grants pass only grant routes A test calls A13 with an owner's email and asserts no session; a grant on S1 answers GRANT_OUT_OF_SCOPE
2 Upgrade sets a password from an email alone A14, upgrade_account line 819 Taking over any email-only account, for example every respondent created by auto-enroll Grant plus a code; email from the grant A test upgrades without a grant (UNAUTHORIZED) and with a grant but no code (refused)
3 API keys act as their creator; the org on the key is never enforced _resolve_api_key, backend/app/utils/auth.py; _api_key_org_id unused A key made in one org reaches every org its creator belongs to, with the creator's full rights, including creating more keys Org-bound keys with scopes; keys cannot manage keys The tenant isolation test runs every registry route with a key from another org and expects 404
4 CORS allows * with credentials backend/app/main.py Any origin is answered as trusted; harmless while tokens are in localStorage, an open door once a cookie is used Same-origin by default, explicit allowlist A test sends a foreign Origin and asserts no Access-Control-Allow-Origin
5 Anyone can join any organization as a respondent, and respondents read the member list and pending invitations A13 with any org id; _ensure_tenant_enrollment from a forged Host on A1 and A8; O9 and O14 allow any member Every member's and invitee's email and name, for any organization, plus draft surveys through S3 Grants create no membership; tenant enrollment from the resolved host in Next middleware, never a raw header on an API call; O9 and O14 become admin only A respondent and a grant on O9 and O14 get FORBIDDEN
6 Unverified accounts are fully usable, and Google, Apple and a code link to them by email A1, A10, A12, A8 Registering first with someone else's email keeps a password into the account they later use A proven email clears a never-verified password and ends its sessions A test registers, then signs in by Google with the same email, and asserts the first password no longer works
7 my-responses and history match by the email typed on the take page S10 stores respondent_email as free text; S12, S13, S17, S18 match on it With issue 1 or 2, reading or deleting another person's answers; anyone can file answers under another email Grants read their own rows; a signed-in respondent's responses carry their user id A grant cannot read a response it did not write
8 Any signed-in person can claim any unclaimed response S11 Attaching someone's anonymous answers to one's own account (ids are guessable cuids, below) Claim token from S10 S11 without the token is refused
9 The survey creator bypasses role checks get_survey_with_org_access, backend/app/utils/permissions.py A creator removed from the org keeps full access to the survey and its responses; an admin creator can delete all responses Roles only (open decision below) A removed creator gets 404 on S3
10 Sign-out revokes nothing A15 A copied token works until it expires Session rows, deleted on A15 and on reset Use after A15 is 401
11 Tokens in URLs A10, A12 redirects Tokens land in browser history and any logging proxy Signed payload exchanged for a cookie No redirect URL carries a session token
12 Single-use tokens stored in plain text, and ids from a non-cryptographic generator email_verifications, password_resets; generate_cuid in backend/app/utils/cuid.py uses random, and invitation tokens are cuids A database read yields live reset links; invitation tokens and public ids are more predictable than they look Hashed tokens from crypto.randomBytes (TourCockpit generateToken); ids kept as data, never as secrets A unit test asserts no token column holds the raw value
13 The deployed dev app exposes every email it would have sent /api/v1/dev/emails (M2) is unauthenticated and registered when ENVIRONMENT is development, which the impactpulse container app sets with external ingress (catalyzeup-devops/azure/main.tf); send_email intercepts every message there Reading any sign-in code or reset link on dev, so signing in as anyone there; whether dev holds real people's data is not known Test routes only under ENVIRONMENT=test, which no deployed app sets The guard test in API Route Map
14 No rate limit on password sign-in, register, auto-enroll or respond, and the API key limit is per replica backend/app/middleware/rate_limit.py covers keys only Password guessing and form flooding Shared limiter with the table above Limits asserted per route
15 Timing and messages tell which emails have accounts A2 timing, A1 "Email already registered", A14 404 "User not found" Listing who uses Sondely Dummy verify, A14 behind a grant; A1 keeps EMAIL_TAKEN (the sign-up form needs it) but is rate limited Timing test as in TourCockpit
16 Apple's id_token is read without verification A12, line 688 Low today (the token comes from Apple directly) Verified against Apple's keys Test with a token signed by another key

Open decisions

Legacy ip_live_ keys

Every existing key is stored as a bcrypt hash keyed by a 12-character prefix, so it cannot be turned into the new SHA-256 hash without the key itself. Keys are also unscoped today, so any carried-over key needs an organization and a scope assigned.

Option What happens Cost
Keep accepting them ApiKey gets a nullable legacyPrefix and legacyBcrypt. A bearer starting ip_live_ is looked up by prefix and checked with bcrypt; on success the SHA-256 hash is written and the bcrypt columns cleared, so each key pays the slow path once. Org is the key's organization_id; scope full (what it can do today, narrowed to one org) One more branch in resolveApiAuth, and the prefix collision stays possible until each key has been used once
Rotate at cutover Every key is revoked; owners and admins make new ones with a scope Every integration breaks at cutover until someone pastes a new key. Count of live keys in production not checked (the dev seed creates one, ip_live_wehappers_dev_key_00001, in backend/app/main.py)

Either way new keys get a new prefix (proposed sd_live_) so the two kinds are told apart at a glance.

"/me with no memberships means admin"

In code:

  • backend/app/api/auth/routes.py, lines 870 to 871, in A16: is_admin = any(r["role"] in ("OWNER", "ADMIN") for r in roles) or len(roles) == 0. The comment says "Admin if OWNER/ADMIN in any org, or new user with no memberships".
  • backend/app/api/dashboard/routes.py, lines 35 to 38, in D1: the admin dashboard is shown to admins and owners "OR new users with no memberships"; the respondent dashboard only to users whose every role is RESPONDENT.
  • frontend/src/contexts/AuthContext.tsx, lines 31 to 32, reads is_admin as is; AdminRoute in frontend/src/App.tsx (lines 69 to 93) sends anyone without it to /dashboard.

What it does: a person who signs up at sondely.com belongs to no organization, so they get the admin screens and can create their first one (O1 accepts any signed-in user). It is a screen rule only: every server route still checks the role in the organization concerned, so it grants no data. Its side effect is that anyone with no membership, for example a person who signed in by code only to see their own results, lands on an empty admin dashboard instead of the respondent one.

Option Effect
Keep it Same screens as today; the TypeScript /me reproduces the rule for parity
Replace it with an explicit state /me returns roles and a separate canCreateOrganization (or an onboarding state); is_admin means owner or admin somewhere and nothing else. Screens decide from the state; respondents without an org see the respondent dashboard

Smaller ones

Decision Options
Creator rule (hole 9) Drop it (roles only), or keep it only while the creator is still owner or admin
Rate limit store A Postgres table shared by all replicas, or pin Sondely to one replica and keep TourCockpit's in-memory limiter
When the cookie replaces the bearer for browsers With the first screen that moves, or after the last
Session windows Proposed 30 days sliding, 90 absolute; TourCockpit uses 90 and 180
Fix holes 5 to 16 in Python first, like 1 to 4 in phase 0 of the migration plan, or only in the TypeScript app 5, 6 and 13 are the ones with the widest reach