sojorn/sojorn_docs/troubleshooting/JWT_401_FIX_2026-01-11.md

3.8 KiB

JWT 401 "Invalid JWT" Fix

Date: January 11, 2026
Issue: Edge Functions returning 401 "Invalid JWT" errors for feed endpoints
Status: Fixed

Problem

The feed-personal and feed-sojorn Edge Functions were returning 401 "Invalid JWT" errors, causing feeds to fail to load despite the user having a valid session.

Symptoms

  • feed-personal and feed-sojorn functions returned 401 with {code: 401, message: "Invalid JWT"}
  • profile function worked fine (no 401 errors)
  • Session refresh didn't resolve the issue
  • Multiple concurrent requests caused repeated refresh attempts

Root Cause

The feed functions were explicitly passing the JWT to supabase.auth.getUser(jwt):

// BEFORE (broken):
const { data: { user }, error: authError } = await supabase.auth.getUser(jwt);

The JWT was extracted from the request's Authorization header. Even after the Flutter client refreshed its session and obtained a new token, subsequent API calls would still send the old/stale JWT in the header because:

  1. The request was already in flight when refresh happened
  2. Cached/old tokens weren't being properly invalidated
  3. The Edge Function validated the stale JWT and rejected it

Meanwhile, the profile function worked because it called supabase.auth.getUser() without passing the JWT explicitly:

// AFTER (fixed):
const { data: { user }, error: authError } = await supabase.auth.getUser();

This lets the Supabase SDK use its internal session state (which gets updated after refresh) rather than trusting the potentially stale header token.

Solution

Edge Functions Changes

Changed both feed-personal and feed-sojorn to NOT pass the JWT to getUser():

// AFTER (fixed):
const { data: { user }, error: authError } = await supabase.auth.getUser();

Flutter App Changes (api_service.dart)

Added proper 401 retry logic:

  1. _callFunction now re-throws FunctionException - Allows callers to catch 401 errors
  2. 401 retry with session refresh in getPersonalFeed, getSojornFeed, and getProfile
  3. Concurrent refresh handling - Multiple simultaneous 401s share a single refresh future via _refreshInFlight
  4. Removed artificial delays - No more unnecessary 500ms/1000ms delays after refresh

Files Modified

  1. supabase/functions/feed-personal/index.ts - Removed JWT parameter from getUser()
  2. supabase/functions/feed-sojorn/index.ts - Removed JWT parameter from getUser()
  3. sojorn_app/lib/services/api_service.dart - Added 401 retry logic with session refresh

How to Deploy

Run the deployment script from the project root:

.\deploy_all_functions.ps1

This uses --no-verify-jwt flag which is required for the supabase-js v2 SDK that supports ES256 JWTs.

Lessons Learned

  1. Don't explicitly pass JWTs to getUser() - Let the SDK handle authentication automatically
  2. The Supabase SDK handles auth internally - Trust its internal session state
  3. Profile function was the clue - It worked because it didn't pass the JWT explicitly
  4. Check how similar functions work - When one function works and another doesn't, compare their implementations

Prevention

When creating new Edge Functions:

  1. Always use supabase.auth.getUser() without passing the JWT parameter
  2. Trust the Supabase SDK's internal session handling
  3. If you need the user's ID, get it from user.id after calling getUser() without parameters
  4. Don't extract and pass JWTs from request headers manually

References