Prism is an interview practice tool — you pick a topic (elocution, system design, etc.), get matched with AI panelists who have distinct personas, and do push-to-talk voice sessions that get transcribed and critiqued. Today was about hardening it for real users.

sign in with everything#

Added OAuth for Google, GitHub, and Apple. Google and GitHub are standard OAuth2 flows. Apple is its own thing:

  • The client secret is a short-lived ES256 JWT you sign with a .p8 private key — not a static string
  • The callback is POST (form_post), not GET
  • User info comes from the id_token JWT, not a userinfo endpoint
  • Apple only sends the user’s name on the first authorization — subsequent logins only have email + sub

Generated a fresh client secret per request to avoid stale-secret bugs. Used ParseUnverified on the id_token since it comes server-to-server from Apple’s token endpoint over TLS.

the proxy problem#

Frontend is on Vercel, backend on Railway. Vercel rewrites proxy HTTP requests (/api/*, /auth/*) to the backend so cookies stay same-origin. This works great until:

  1. OAuth state cookies — the redirect chain through Vercel’s proxy doesn’t reliably round-trip cookies. Replaced cookie-based CSRF state with HMAC-signed state parameters: base64(nonce + hmac(nonce, secret)). Stateless, no cookies needed, verified by recomputing the HMAC.

  2. WebSockets — Vercel can’t proxy WebSocket connections. Added NEXT_PUBLIC_WS_URL pointing directly to Railway. But cross-origin WS connections don’t send cookies, so added a /api/auth/ws-token endpoint — frontend fetches the JWT through the proxy, then passes it as ?token= on the direct WS connection. Auth middleware checks both cookie and query param.

safari and MediaRecorder#

Push-to-talk uses the MediaRecorder API. Safari reports audio/mp4 as supported, but its implementation has quirks:

  • start(timeslice) with a timeslice parameter doesn’t fire ondataavailable — it buffers everything silently
  • The ondataavailable property-based handler is unreliable — events may not fire, or fire after onstop

First fix was skipping the timeslice. Still got 0 chunks after 6+ seconds of recording. The real fix: use addEventListener("dataavailable", ...) instead of the property assignment, and for Safari’s stop path, capture the blob directly from the dataavailable event rather than accumulating chunks. Bypasses the ordering issue entirely.

what shipped#

  • Three OAuth providers (Google, GitHub, Apple) with HMAC-signed state
  • Cross-origin WebSocket auth via token query parameter
  • Dashboard CTA that’s actually visible for new users
  • Safari-compatible push-to-talk audio recording
  • Idempotent database migrations (IF NOT EXISTS on all CREATE statements — learned that one the hard way)