Emailzap · Mobile Authentication

How mobile auth is wired vs chromex session cookies vs lambda (no end-user auth). Evidence from worktree 7bz0 code + design docs. Click cards, flow nodes, or matrix rows for paths. Claims marked uncertain are flagged — ask before treating as fact.

Wired (code + caller path) Partial / doc drift / unused client field Dead / superseded / never shipped Shared OAuth / identity model solid = clickable detail · dashed = external

Need the vocabulary first? Open the Theory tab — cookies, Django sessions, JWT, PKCE, Bearer, CSRF, SecureStore — with official-doc links. Emailzap wiring lives in the other tabs.

Doc drift: docs/specs/mobile-app/05-mobile-auth-design.md §D-E still describes rotating refresh. Live code is non-rotating (token_service.refresh). Prefer code + backend/mobile/docs/mobile-auth-api.md over that design section.

0 · Vocabulary used

Terms used across this artifact. Official meaning first; Emailzap usage in the right column when it differs or specializes.

TermMeaning (official / general)In this doc / Emailzap
Mint (verb) Not an official IETF/OAuth term. Informal engineering jargon meaning issue / create / generate a new credential (token, code, JWT). RFCs usually say “issue,” “create,” or “obtain” (e.g. RFC 6749 refresh tokens are used to obtain access tokens). Code/docs say mint_bearer_pair, “mint OTC,” “mint access” = server creates that credential. Same meaning as issue.
Cookie HTTP state: server Set-Cookie → user-agent stores → client sends Cookie on later matching requests (MDN, RFC 6265). Chromex/web transport for Django sessionid. Mobile API auth does not use cookies.
Session Server-side (usually) bag of data for one visitor; cookie often holds only the session key (Django sessions). Also colloquially: “logged-in period.” Django django_session + sessionid cookie (chromex). Mobile “session” in session-store.ts = SecureStore tokens + cached identity — not a Django session.
Session cookie (MDN sense) Cookie with no Max-Age/Expires — discarded when browser session ends (MDN). Easy to confuse with “session id cookie.” Django’s sessionid is typically persistent (SESSION_COOKIE_AGE), not an MDN “session cookie.” Naming collision — see § Session vs cookie.
Bearer token Credential sent as Authorization: Bearer …; possession is enough (RFC 6750). Mobile Emailzap API access JWT. Header set by axios interceptor.
Access token Short-lived credential to call a protected API (OAuth / app-defined). Mobile: HS256 JWT purpose=bearer_access (~1h). Google’s Gmail access token is a different access token.
Refresh token Longer-lived credential used to obtain new access tokens without re-login (RFC 6749 §1.5). Mobile Emailzap: opaque string; SHA-256 in Mongo. Google refresh token (Fernet on gmail_subscriptions) is separate.
JWT Signed/encrypted JSON claims compact encoding (RFC 7519). Mobile access (+ apple_link / add_account / reconnect_only). Refresh ≠ JWT.
OAuth 2.0 Delegation protocol: user authorizes client to access resources at a resource server (RFC 6749). Not itself “login to your app,” though apps often wrap it for signup. Google OAuth obtains Gmail API scopes + identity. Emailzap then creates Django/Mongo user and issues its own session or bearer.
Authorization code Short-lived code from IdP redirect; exchanged for tokens at token endpoint. Google returns a code to Emailzap backend; backend talks to Google. Mobile client never holds Google’s code — it receives Emailzap OTC instead.
OTC (one-time code) App-specific short-lived code (not an OAuth standard name). Emailzap deep-link emailzap://callback?code=POST /mobile/auth/exchange/. Bridging Google OAuth finish → mobile bearer mint.
PKCE Proof Key for Code Exchange — binds code redeem to the client that started auth (RFC 7636). Mobile: binds OTC exchange (and related flows), not Google’s token endpoint directly from the app.
CSRF Cross-Site Request Forgery — abuse of auto-sent cookies (OWASP). Relevant for cookie session. Bearer header not auto-attached by browsers.
IdP / authorization server Issues tokens / runs consent (here: Google). Google for Gmail + sign-in. Apple for Sign in with Apple (mobile).
Resource server API that accepts access tokens (RFC 6749). Google APIs (Gmail) with Google tokens; Emailzap API with session cookie or Emailzap bearer.

Sources linked per row.

1 · Cookie

A cookie is a small name/value pair the user-agent (browser, or extension cookie API) stores after a Set-Cookie response and returns in the Cookie request header when URL/domain/path/SameSite rules match.

1. Server → 200 OK Set-Cookie: sessionid=abc…; Path=/; Secure; HttpOnly; SameSite=None 2. Browser stores cookie for that domain/path. 3. Later request to same site → Cookie: sessionid=abc… 4. Server reads Cookie header. If this is a Django session key, it loads server-side session data for key abc…
  • Cookies are transport + storage owned by the user-agent — not “the session itself” and not the same as a Bearer header the app attaches.
  • MDN “session cookie” = no Max-Age/Expires (dies when browser session ends). Persistent cookie = has expiry. Separate idea from “session id cookie” used for server sessions.
  • Native mobile apps usually have no shared browser cookie jar for your API host → Bearer + SecureStore is the common substitute.
  • A cookie value can be anything (prefs, CSRF token, opaque id). Meaning is defined by the server that set it.

Sources: MDN Cookies guide MDN Set-Cookie RFC 6265

in Emailzap Chromex: sessionid (+ csrftoken, user_id, …). Mobile API auth: no cookie credential — see Storage tab.

3 · HttpOnly, Secure, SameSite

Cookie attributes on Set-Cookie control who can read the cookie and when the browser sends it.

  • HttpOnly — JavaScript cannot read the cookie via document.cookie. Still sent on HTTP requests initiated by JS (fetch / XHR). Mitigates XSS stealing session cookies — MDN.
  • Secure — cookie only sent over HTTPS (localhost exception). Resists passive network sniffing — MDN Set-Cookie.
  • SameSite — when cookie is attached on cross-site requests: Strict / Lax / None. Helps CSRF / third-party cookie control. SameSite=None requires SecureMDN SameSite.

HttpOnly blocks page JS. Browser extensions with the cookies permission (Chrome) can still read cookies via chrome.cookies — different privilege model than a webpage.

Sources: MDN Cookies MDN Set-Cookie

in Emailzap Prod/dev set session/CSRF cookies Secure=True, SameSite=None, domain .emailzap.co (see execfn/settings/prod.py / dev.py). Django’s default session cookie is HttpOnly unless overridden — Django docs recommend leaving SESSION_COOKIE_HTTPONLY True.

4 · Django sessions (database by default)

Django’s session framework stores arbitrary data server-side and puts only a session key in the cookie (unless you choose the signed-cookie backend). Quote from Django docs: cookies contain a session ID — not the data itself (unless using the cookie-based backend).

Browser cookie: sessionid = <random key> Server table: django_session session_key | session_data (encoded) | expire_date On each request: SessionMiddleware reads cookie → loads row → request.session login(request, user) attaches user id into that session data.
  • Default engine: database-backed sessions via model django.contrib.sessions.models.Session. Enable django.contrib.sessions + migrate → creates the table — Django 5.2 sessions.
  • Other engines (must set SESSION_ENGINE): cache, cached_db, file, signed_cookies. Signed-cookie backend stores data in the cookie (signed, not encrypted) — different threat model.
  • Logout: logout(request) flushes server session data. Stealing a live sessionid still works until expiry/flush — classic session hijacking.
  • Age: SESSION_COOKIE_AGE controls lifetime (Django default 2 weeks; projects may override).

Sources: Django: How to use sessions SESSION_COOKIE_AGE auth.login

in Emailzap No SESSION_ENGINE override found → default database sessions (django_session). SESSION_COOKIE_AGE = 400 days in base.py. Mobile OAuth branch does not call login() / set sessionid.

5 · CSRF (why cookies need extra protection)

If the browser auto-sends cookies, a malicious site can trigger a request to your API and the cookie rides along. CSRF defenses prove the request was intentionally initiated from your origin.

  • Django’s CsrfViewMiddleware checks a CSRF token (cookie + form/header) on unsafe methods — Django CSRF protection.
  • Bearer tokens in Authorization headers are not auto-attached by the browser to third-party pages, so classic cookie-CSRF does not apply the same way — still protect against XSS stealing the token.
  • SameSite cookies also reduce CSRF surface (MDN), but are not a full substitute for CSRF tokens in all browsers/flows.

Sources: Django CSRF OWASP CSRF

in Emailzap Chromex: ExtensionCSRFMiddleware skips CSRF checks when Origin is an extension and sessionid is present. Mobile bearer: CSRF N/A for the Authorization header pattern.

6 · Bearer tokens

RFC 6750 defines using an access token as a Bearer credential. Client proves identity to your API by sending the token explicitly — the browser does not auto-attach it like a cookie.

Authorization: Bearer <access_token> Client must: 1. Obtain token (login / refresh) 2. Store it securely (e.g. Keychain via SecureStore) 3. Attach header on every API request 4. Refresh or re-login when access expires / 401
  • “Bearer” = possession is authentication — treat like a password; HTTPS required in practice.
  • Unlike cookies: no automatic send on cross-site form posts → classic cookie-CSRF does not apply the same way (still guard XSS).
  • Often paired with a long-lived refresh token to mint (issue/create) new short-lived access tokens (RFC 6749 §1.5 says refresh tokens are used to obtain access tokens — “mint” is informal synonym). Rotation policy is implementation-defined. See Vocabulary → Mint.
  • Access token format is not fixed by Bearer RFC — can be opaque or JWT. Bearer only specifies how it is sent.

Sources: RFC 6750 Bearer RFC 6749 refresh tokens

in Emailzap Mobile axios sets Bearer. Access = HS256 JWT (~1h). Refresh = opaque, non-rotating idle-slide (60d). Chromex does not use Bearer for Emailzap API — uses sessionid.

7 · Bearer vs OAuth — how Google OAuth fits

These layers get conflated. OAuth is a delegation protocol (get permission to call Google). Bearer is a credential transport (how you send some access token). An app can use OAuth without Bearer-to-its-own-API (chromex cookies), or Bearer without talking to Google on every request (mobile after login).

OAuth 2.0 (e.g. Google)Bearer to Emailzap APIDjango session cookie
Question it answers“May this app call Gmail as the user?”“Is this request from a logged-in Emailzap mobile user?”“Is this request from a logged-in Emailzap chromex/web user?”
Who issues the credential?Google (authorization server)Emailzap backendEmailzap Django session
What does it unlock?Google APIs (Gmail scopes)Emailzap REST/SSE as that userSame Emailzap API via cookie
How sent?Backend uses Google access token vs Google (server-side)Authorization: BearerCookie: sessionid=…
Stored where (typical)?Server: encrypted on gmail_subscriptionsClient SecureStore + server hash of refreshClient cookie jar + django_session
TWO DIFFERENT “ACCESS TOKENS” AFTER GOOGLE SIGN-IN ┌─────────────┐ OAuth code flow ┌──────────────┐ │ Mobile/Chromex│ ─────────────────────► │ Google IdP │ │ (browser UI) │ ◄── consent + code ─── │ │ └──────┬────────┘ └──────┬───────┘ │ opens Emailzap /google-auth/… │ ▼ ▼ ┌──────────────────────────────────────────────────────────┐ │ Emailzap backend │ │ 1. Exchange Google code → Google access + refresh │ │ 2. Store Google tokens (Fernet) on gmail_subscriptions │ │ 3. Create/update User + MongoUser │ │ 4a. Chromex: login() → Set-Cookie sessionid │ │ 4b. Mobile: mint OTC → deep link → exchange → │ │ Emailzap Bearer JWT + Emailzap refresh │ └──────────────────────────────────────────────────────────┘ Later Gmail sync (backend/lambda) uses Google tokens. Later inbox UI API calls use sessionid OR Emailzap Bearer — never Google’s token from the client.
  • OAuth does not replace app login by itself — RFC 6749 is about authorizing access to a resource server. Apps commonly also create a local account/session after OAuth succeeds.
  • Bearer is how mobile proves Emailzap login after that handoff. Chromex proves the same with a cookie instead.
  • Google’s Bearer token (to Google APIs) stays on the server. Mobile/chromex clients do not attach Google’s token to Emailzap API calls.
  • Apple Sign-In is a parallel IdP path for mobile identity; Gmail still needs Google OAuth for mailbox scopes when linking.

Sources: RFC 6749 OAuth 2.0 RFC 6750 Bearer Google OAuth 2.0 Django sessions

in Emailzap Shared /accounts/google-auth/*. Fork: chromex gets cookies; mobile gets OTC→bearer. Both paths write the same Gmail subscription tokens.

8 · OAuth 2.0 authorization code (sketch)

OAuth lets a user grant an app limited access without sharing their Google password with the app. Authorization Code flow: browser visits authorization server → user consents → app receives a short-lived code → app exchanges code (+ secrets/PKCE) for tokens.

App → Authorization endpoint (user login/consent at Google) ← redirect with ?code=… App → Token endpoint (code + client auth and/or PKCE verifier) ← access_token (+ refresh_token) for Google APIs
  • Defined in RFC 6749. Public clients (mobile/SPA) cannot hold a confidential client secret safely — PKCE was designed for that class.
  • The Google access token (for Gmail API) is separate from Emailzap’s session / bearer credential that authenticates to Emailzap’s own API — see §7.

Sources: RFC 6749 OAuth 2.0 Google OAuth 2.0

in Emailzap Both chromex and mobile open the same backend /accounts/google-auth/login/ which redirects to Google. Backend keeps Gmail tokens on MongoGmailSubscription. Mobile additionally mints OTC → Emailzap bearer pair.

9 · PKCE (Proof Key for Code Exchange)

RFC 7636: public OAuth clients are vulnerable to authorization code interception (another app on the device steals the redirect code). PKCE binds the token exchange to the client that started the flow.

1. Client creates random code_verifier (high entropy) 2. code_challenge = BASE64URL(SHA256(code_verifier)) # method S256 3. Send code_challenge with authorization request 4. Later, token/exchange request must include code_verifier 5. Server recomputes challenge; mismatch → reject
  • Pronounced “pixy” (RFC abstract).
  • S256 is the recommended challenge method; plain exists but is weaker.
  • Verifier must stay secret until exchange — typically memory only, never logged.

Sources: RFC 7636 oauth.net PKCE

in Emailzap Mobile generates PKCE before Google/Apple flows; challenge stored with OTC; /exchange/ requires verifier. Defends emailzap://callback?code= custom-scheme interception.

10 · JWT (JSON Web Token)

RFC 7519: compact, URL-safe representation of claims between two parties. Typically three Base64url parts: header.payload.signature (JWS).

  • Claims are JSON fields (e.g. exp, iat, custom ids). Anyone who has the token can read unsigned/signed payloads unless encrypted (JWE).
  • Signature (e.g. HS256 = HMAC-SHA256 with a shared secret) proves integrity + authenticity of claims — not confidentiality.
  • exp — expiration time; verifiers must reject expired JWTs (RFC 7519 §4.1.4).
  • Stateless access: server can verify signature without a DB row for that token (still may check user still exists / not deleted).
eyJhbGciOiJIUzI1NiJ9. ← header { alg: HS256 } eyJzdWIiOiIxMjMifQ. ← payload claims SflKxwR… ← signature

Sources: RFC 7519 JWT RFC 7515 JWS jwt.io introduction

in Emailzap Mobile access token = HS256 JWT, purpose bearer_access, TTL 1h, secret MOBILE_JWT_SECRET. Refresh token is not a JWT — opaque random; only SHA-256 stored in Mongo.

11 · expo-secure-store

Expo’s official API for encrypting and storing key–value pairs on device — intended for secrets, not bulk app state.

  • iOS: Keychain Services as kSecClassGenericPassword. Data may persist across uninstall/reinstall with same bundle ID (Keychain behavior; Expo says do not rely on this as a guarantee) — Expo SecureStore.
  • Android: values in SharedPreferences, encrypted with Android Keystore. Cleared on uninstall.
  • Not a substitute for server authority — Expo: do not rely on it as sole SoT for irreplaceable critical data.
  • Large values can be rejected by the platform (historical ~2048 byte iOS limit noted in Expo docs).

Contrast: AsyncStorage / MMKV are general local storage — not hardware-backed secret stores. Expo and mobile security guidance: keep credentials out of them.

Sources: Expo SecureStore docs Apple Keychain Services Android Keystore

in Emailzap session-store.ts uses SecureStore for access/refresh/user/accounts with env-namespaced keys. PKCE verifier stays in memory. MMKV used for query cache — cleared on logout, not for tokens.

12 · chrome.cookies (extension privilege)

Chrome extensions with the "cookies" permission can read/write/remove cookies for allowed URLs — including HttpOnly cookies that page JS cannot see — via chrome.cookies.get/set/remove.

  • Documented in Chrome Cookies API.
  • chrome.identity.launchWebAuthFlow opens an auth window and returns the redirect URL — common for OAuth in extensions — chrome.identity.

Sources: chrome.cookies chrome.identity

in Emailzap Chromex auth derives from backend sessionid presence; teardown removes cookies via this API.

13 · Django / DRF authentication mechanisms

Two different layers people call “auth.” Mixing them causes confusion.

Django auth system

  • Users, permissions, groups, password hashing, login()/logout()
  • Auth backends (AUTHENTICATION_BACKENDS) — how credentials map to a User (default ModelBackend)
  • Cookie-based sessions via SessionMiddleware + AuthenticationMiddleware
  • Docs: User authentication in Django

DRF authentication classes

  • Per-request: how an API call proves who it is → sets request.user / request.auth
  • List of classes tried in order; first success wins (DRF Authentication)
  • Auth alone does not allow/deny — permissions do
  • Built-ins: Session, Basic, Token; custom / third-party JWT common

What “session authentication” means in Django

Session auth = “this request is tied to a logged-in User via Django’s session framework,” not “we store passwords in cookies.”

1. User authenticates once (password form, OAuth callback calling login(), …) 2. django.contrib.auth.login(request, user) → writes user id into request.session (server-side) 3. SessionMiddleware ensures session key cookie (sessionid) is set/sent 4. Later request: Cookie: sessionid=… → SessionMiddleware loads session → AuthenticationMiddleware sets request.user from session 5. DRF SessionAuthentication: same session → request.user for API views Logout: logout(request) flushes session data; delete cookie drops client pointer.
  • Django overview: auth system handles accounts + cookie-based user sessionsDjango auth.
  • DRF: SessionAuthentication “uses Django's default session backend… appropriate for AJAX clients that are running in the same session context as your website” — DRF SessionAuthentication.
  • Unsafe methods (POST/PUT/PATCH/DELETE) need a valid CSRF token when using SessionAuthentication (DRF + Django CSRF docs).
  • Bare DRF SessionAuthentication often returns 403 when unauthenticated (no authenticate_header). Projects may subclass to return 401 (Emailzap does).

Common mechanisms — web vs mobile vs both

Mechanism Typical client How it works Security concerns Pros Cons Recommended when
Session auth
Django + DRF SessionAuthentication
Web / browser extension Server session row; client holds sessionid cookie; browser auto-sends; CSRF on mutating requests. CSRF; session fixation/hijack if cookie stolen; XSS if cookie not HttpOnly; long-lived cookies widen window. Native browser jar; logout can flush server session; fits same-site web apps; no custom header plumbing. Awkward for pure native mobile (no cookie jar); CSRF complexity; multi-device revocation is session-row oriented. First-party web UI / SPA same-site as API; browser extensions that can use cookies. DRF: AJAX in same session context.
DRF TokenAuthentication
Authorization: Token …
Mobile / desktop (DRF says so) Opaque token in DB (authtoken); client sends header each request. Token theft = full access until rotated; default scheme is simple (often one token/user, weak expiry story) — DRF points to Knox for tighter design; HTTPS required. Simple; no CSRF; works without browser cookies; easy to test with curl. Built-in token model is minimal (expiry/rotation/multi-device often DIY or Knox); long-lived static tokens risky. Simple client-server APIs; native clients when you accept DRF’s simple token model or adopt Knox/similar. Not used in Emailzap (no authtoken found).
JWT Bearer (custom / libs)
Authorization: Bearer <jwt>
Mobile / SPA (common) Signed claims (often short-lived access) + optional refresh; client stores securely; server verifies signature (+ optional denylist/refresh store). XSS/theft of token; algorithm/secret mismanagement; “stateless” JWT survives user delete until exp unless you gate; refresh-token storage & rotation design matter. No CSRF for header auth; works on native apps; short access TTL limits blast radius; scalable verify without session row per request. Must build refresh/logout/revocation carefully; clock skew; larger design surface than sessions. Native mobile / non-browser clients; APIs that cannot rely on cookie jar. Pair with SecureStore/Keychain. Emailzap mobile path.
HTTP Basic
DRF BasicAuthentication
Rare in prod UIs Username/password on every request (Base64 in header). Credentials on the wire every call; never store password in client storage (DRF warning); HTTPS mandatory. Trivial for manual testing. DRF: “generally only appropriate for testing.” Local/dev probes only — not production mobile/web product auth.
OAuth 2.0 / OIDC (Google, Apple…) Both (login / scopes) User consents at IdP; app gets codes/tokens for IdP resources and/or identity assertions; app still needs its own session or bearer for its API. Redirect/code interception (mitigate with PKCE); confused-deputy; storing third-party refresh tokens; scoping too broadly. No password to store for Google; standard consent UX; fine-grained scopes for Gmail etc. Does not by itself authenticate every later call to your API — you still issue session/bearer. Sign-in / mailbox access via Google or Apple. Emailzap: Google for Gmail + identity; then session (chromex) or OTC→bearer (mobile).
Additive multi-class
DRF list
Both e.g. [SessionAuthentication, MobileBearerAuthentication] — try session, else Bearer. Mis-ordered classes; CSRF only on session path; ensure both paths hit same permission/soft-delete gates. One API serves web cookie clients and mobile Bearer clients. More code paths to test; subtle 401/403 differences per first class. Shared backend for browser + mobile — Emailzap pattern on many mailbot views.

Web vs mobile — how they differ in practice

Web / chromex (session)Mobile (Bearer JWT + refresh)
Credentialsessionid cookie → django_sessionAccess JWT in Authorization + opaque refresh in SecureStore / Mongo hash
Who attaches credential?Browser / chrome.cookies automaticallyApp interceptor must attach
CSRFRequired for unsafe methods (DRF SessionAuthentication)Not cookie-CSRF; protect tokens from XSS / device malware
LogoutFlush session (+ clear cookies)Revoke refresh (+ clear SecureStore); access JWT dies at exp
DRF classSessionAuthentication (Emailzap: …With401)Custom MobileBearerAuthentication
Getting inOften OAuth → login()OAuth → OTC → exchange → bearer pair (or Apple → bearer)

Security checklist (mechanism-specific)

  • Session: HttpOnly + Secure + sensible SameSite; CSRF tokens; short enough SESSION_COOKIE_AGE for risk appetite; regenerate session on login (Django login flow); HTTPS.
  • DRF Token: HTTPS; treat token like password; plan rotation/compromise; consider Knox if you need expiry/multi-token — per DRF docs.
  • JWT Bearer: strong secret/keys; short access TTL; validate exp/alg/purpose; soft-delete/revocation gates; store refresh in secure storage; never put secrets in JWT payload expecting privacy (signed ≠ encrypted).
  • Basic: HTTPS only; no persistent password storage on client — DRF.
  • OAuth: PKCE for public clients; minimal scopes; protect refresh tokens at rest (Fernet etc.).

Sources: Django authentication Django auth in web requests DRF Authentication DRF SessionAuthentication DRF TokenAuthentication DRF BasicAuthentication Django CSRF

in Emailzap Default DRF = SessionAuthenticationWith401. Mobile = custom Bearer JWT + refresh (not DRF TokenAuthentication). Many product views: session + bearer additive. Google/Apple OAuth = how users enter; not the per-request Emailzap API credential.

14 · Theory → Emailzap quick map

ConceptChromex / webMobile
Credential transportCookie sessionidAuthorization: Bearer
Server storedjango_session rowJWT verify + mobile_refresh_tokens hash
Client storeBrowser cookie jarexpo-secure-store
CSRFMiddleware (+ extension bypass)N/A for bearer header
Google OAuthSame backend endpointsSame + mobile=true + PKCE/OTC
Mailbox Google tokensgmail_subscriptions (Fernet) — separate from either session credential

Open other tabs for wiring evidence. Theory tab stays vocabulary + official refs.

1 · Google sign-in (mobile) — wired

Same Google OAuth client/scopes as chromex. Branch: ?mobile=true → OTC deep-link, no sessionid.

Mobile client
Shared OAuth (accounts)
externalGoogle consent screensame OAuth client + scopes as chromex
Token mint

2 · Apple Sign-In (mobile iOS) — wired

Side-door into a Google-anchored family. Unlinked Apple → in-memory apple_link_token → AI consent / Connect Gmail (not SecureStore).

Linked Apple (returning)
Unlinked Apple (first time)

3 · Per-request auth + refresh — wired

Request
Backend
401 recovery

4 · Logout — wired (mobile) / cookie-clear (chromex)

Mobile
Chromex

5 · Chromex login (comparison) — wired

Chromex
Backend

Design intent from 05-mobile-auth-design.md §1–2, verified against live code where noted.

Mobile

  • Credential: Authorization: Bearer <jwt>
  • Storage: expo-secure-store (env-namespaced keys)
  • OAuth UI: WebBrowser.openAuthSessionAsync
  • CSRF: N/A (bearer)
  • Apple Sign-In: yes (iOS)
  • Header: X-Emailzap-Client: mobile
  • Refresh: client interceptor → non-rotating

Chromex

  • Credential: Django sessionid cookie
  • Storage: browser cookie jar + chrome.storage metadata
  • OAuth UI: chrome.identity.launchWebAuthFlow
  • CSRF: bypassed via ExtensionCSRFMiddleware when session present
  • Apple Sign-In: no
  • Query/identity: client=chromex / installation id
  • Refresh: Django session age (400 days)

Shared (unchanged by mobile auth)

Mobile SecureStore keys

KindKey patternContents
accessmobile_access_token_{env}HS256 JWT (1h)
refreshmobile_refresh_token_{env}Opaque random (server stores SHA-256 only)
usermobile_user_{env}Cached identity JSON (Option C cold start)
accountsmobile_accounts_{env}Cached account family JSON

Env = local | dev | production. Cross-env cleanup on first read (Keychain bleed defense for shared bundle IDs).

What is NOT used for mobile credentials

Backend cookies (chromex / web path)

CookieSet whenCleared by LogoutAPIViewNotes
sessionidDjango login() on OAuth callback (non-mobile)yesPrimary auth credential for chromex
csrftokenDjango CSRFyesBypassed for extension Origin + session
user_idset_cookies after OAuthyeshttponly=False in set_cookies
profileprofile switch pathsyes
tokennever setyes (delete only)Likely legacy

Auth source of truth for mobile tokens / OTC / Apple = Mongo. Postgres tables with the same names are dual-write projections (not used to authenticate requests).

Mongo collections (auth-relevant)

CollectionModelRepresentsUsed by

Postgres tables (auth-relevant)

TableModelRepresentsRole in auth

Mobile auth routes · /api/v1/mobile/

Method · pathAuthRole

Shared Google OAuth · /api/v1/accounts/google-auth/

Early-return when ?mobile=true or state.metadata.mobile. Chromex continues without those flags.

EndpointMobile branchChromex branch
GET …/login/_mobile_login → Google + PKCE in stateStandard OAuth start
GET …/callback/_mobile_callback → OTC deep-link (no session)login() + set_cookies
GET …/google-add/_mobile_google_add via add_account_tokenSession-authed add secondary
GET …/add-account-callback/_mobile_add_account_callback → OTCCookie session path

Wired vs dead across surfaces. Click a row for evidence.

SurfacePathStatus

Ask me if…