Skip to content

Supabase Auth-JS Deadlock Issue #2013

@datrine

Description

@datrine

Describe the bug

Supabase Auth-JS Deadlock Issue

Summary

supabase.auth.signInWithPassword() and supabase.auth.setSession() hang indefinitely after receiving a successful 200 OK response from the Supabase auth API. The deadlock occurs in the _acquireLock mechanism during session storage operations.

Environment

  • Package: @supabase/auth-js
  • Version: Latest (as of January 2026)
  • Browser: Chrome/Edge (Chromium-based)
  • Platform: Windows
  • Framework: React + Vite

Problem Description

Observed Behavior

  1. Call supabase.auth.signInWithPassword({ email, password })
  2. Network tab shows successful 200 OK response from /auth/v1/token?grant_type=password
  3. Response body contains valid session data (access_token, refresh_token, user)
  4. Function never resolves or rejects - hangs indefinitely
  5. Browser tab becomes unresponsive
  6. Same issue occurs with supabase.auth.setSession()

Expected Behavior

  • Function should resolve with session data after successful authentication
  • Session should be saved to storage
  • User should be signed in

Root Cause Analysis

Primary Issue: Deadlock in _acquireLock Mechanism

File: node_modules/@supabase/auth-js/src/GoTrueClient.ts

Line 1485-1549: _acquireLock implementation

private async _acquireLock<R>(acquireTimeout: number, fn: () => Promise<R>): Promise<R> {
  // ...
  return await this.lock(`lock:${this.storageKey}`, acquireTimeout, async () => {
    this.lockAcquired = true
    const result = fn()
    // ...
    await result
    // ...
  })
}

Line 672-713: signInWithPassword calls _acquireLock

async signInWithPassword(credentials: SignInWithPasswordCredentials): Promise<AuthResponse> {
  // ...
  return this._acquireLock(-1, async () => {
    return await this._signInWithPassword(credentials)
  })
}

Line 1854-1856: setSession also calls _acquireLock

async setSession(currentSession: { access_token: string; refresh_token: string }): Promise<AuthResponse> {
  await this.initializePromise
  return await this._acquireLock(-1, async () => {
    return await this._setSession(currentSession)
  })
}

Line 2769-2816: _saveSession attempts storage operations while lock is held

protected async _saveSession(session: Session) {
  // ...
  await this.storage.setItem(this.storageKey, JSON.stringify(session))
  // This storage operation hangs when lock is already acquired
}

Deadlock Chain

  1. Step 1: signInWithPassword() → acquires navigator.locks lock → calls _signInWithPassword()
  2. Step 2: _signInWithPassword() → receives 200 OK from API → calls _saveSession()
  3. Step 3: _saveSession() → attempts storage.setItem() while lock is held → HANGS
  4. Lock never releases → Promise never resolves → Function hangs forever

Why the Lock Mechanism Fails

The _acquireLock method uses navigator.locks API (via this.lock()) which creates a mutual exclusion lock across tabs/windows. However:

  1. Reentrancy Issue: The lock is not properly reentrant - storage operations inside the lock can trigger additional lock-related events
  2. Storage Contention: Browser's storage API may try to acquire its own locks, conflicting with navigator.locks
  3. Event Loop Blocking: The lock mechanism may be blocking the event loop, preventing storage operations from completing
  4. Race Condition: Multiple async operations within the lock (setItemAsync, deepClone) may create circular wait conditions

Steps to Reproduce

Minimal Reproduction

import { createClient } from '@supabase/supabase-js';

const supabase = createClient(
  'https://your-project.supabase.co',
  'your-anon-key',
  {
    auth: {
      autoRefreshToken: true,
      persistSession: true,
      storageKey: 'fashion_web_session',
      storage: window.localStorage, // Or window.sessionStorage
    },
  }
);

// This will hang indefinitely
const { data, error } = await supabase.auth.signInWithPassword({
  email: '[email protected]',
  password: 'validpassword123',
});

console.log('This line never executes');

Debug Output

Console logs show:

1. Before signInWithPassword call
2. Network: 200 OK from /auth/v1/token
3. Response contains: { access_token, refresh_token, user, ... }
4. [HANGS - no further logs]

Network tab shows:

POST /auth/v1/token?grant_type=password
Status: 200 OK
Response: Valid session JSON

Affected Methods

All methods using _acquireLock are potentially affected:

  • signInWithPassword() (Line 672)
  • signUp() (Line 752)
  • signInWithOAuth() (Line 1386)
  • signInWithIdToken() (Line 1473)
  • setSession() (Line 1854) ⚠️ Cannot be used as workaround
  • refreshSession() (Line 1927)
  • getSession() (Line 1719)
  • signOut() (Line 2141)
  • And 10+ other auth methods

Impact

Severity: CRITICAL

  • Completely blocks user authentication
  • No workaround using official SDK
  • Affects all authentication methods
  • Production applications cannot function

Scope

  • All browser-based applications using @supabase/auth-js
  • Both localStorage and sessionStorage configurations
  • All Chromium-based browsers (Chrome, Edge, Brave, etc.)

Proposed Fix

Option 1: Remove Navigator Locks Dependency (Recommended)

Replace navigator.locks with a simpler in-memory lock mechanism that doesn't interact with browser APIs:

private lockQueue: Promise<any> = Promise.resolve();

private async _acquireLock<R>(acquireTimeout: number, fn: () => Promise<R>): Promise<R> {
  const currentLock = this.lockQueue;
  
  let releaseLock: () => void;
  this.lockQueue = new Promise(resolve => {
    releaseLock = resolve;
  });

  try {
    await currentLock; // Wait for previous operation
    return await fn();
  } finally {
    releaseLock!(); // Release for next operation
  }
}

Option 2: Make Storage Operations Non-Blocking

Move storage operations outside the lock acquisition:

protected async _saveSession(session: Session) {
  const serialized = JSON.stringify(session);
  
  // Release lock before storage operation
  await Promise.resolve();
  
  // Now safe to write to storage
  await this.storage.setItem(this.storageKey, serialized);
}

Option 3: Add Timeout and Recovery

Add timeout mechanism to prevent infinite hangs:

private async _acquireLock<R>(acquireTimeout: number, fn: () => Promise<R>): Promise<R> {
  const timeoutMs = acquireTimeout === -1 ? 10000 : acquireTimeout; // Default 10s max
  
  const timeout = new Promise((_, reject) => 
    setTimeout(() => reject(new Error('Lock acquisition timeout')), timeoutMs)
  );
  
  return await Promise.race([
    this.lock(`lock:${this.storageKey}`, acquireTimeout, fn),
    timeout,
  ]);
}

Workaround (Current Implementation)

Since the official SDK is broken, we've implemented a raw HTTP API approach:

Raw HTTP Sign-In

const response = await fetch(`${supabaseUrl}/auth/v1/token?grant_type=password`, {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    'apikey': supabaseAnonKey,
    'Authorization': `Bearer ${supabaseAnonKey}`,
  },
  body: JSON.stringify({ email, password }),
});

const authData = await response.json();

// Manual session storage (bypass Supabase client completely)
sessionStorage.setItem('fashion_web_session', JSON.stringify({
  access_token: authData.access_token,
  refresh_token: authData.refresh_token,
  expires_in: authData.expires_in,
  expires_at: Math.floor(Date.now() / 1000) + authData.expires_in,
  user: authData.user,
}));

Note: Cannot use supabase.auth.setSession() as workaround - it uses the same broken _acquireLock mechanism.

Testing Recommendations

Test Cases

  1. ✅ Sign in with valid credentials
  2. ✅ Sign in with invalid credentials
  3. ✅ Sign up new user
  4. ✅ Refresh expired token
  5. ✅ Sign out
  6. ✅ Concurrent auth operations across multiple tabs
  7. ✅ Storage quota exceeded scenarios
  8. ✅ Network failure recovery

Lock Contention Tests

  • Multiple tabs attempting sign-in simultaneously
  • Rapid successive auth calls
  • Background token refresh during user interaction
  • Browser back/forward navigation during auth

Additional Context

Browser Console Warnings

None - the hang is silent with no errors logged

Storage Configuration

{
  auth: {
    autoRefreshToken: true,
    persistSession: true,
    storageKey: 'fashion_web_session',
    storage: window.sessionStorage, // Issue occurs with both localStorage and sessionStorage
  }
}

Related Issues

  • Similar to Web Locks API issues in other libraries
  • May be related to storage event listeners in cross-tab synchronization
  • Potentially related to async storage adapter implementations

References


Reporter: Datrisoft
Date: January 10, 2026
Priority: P0 - Critical
Category: Bug - Deadlock/Hang

Library affected

supabase-js

Reproduction

No response

Steps to reproduce

No response

System Info

Package: @supabase/supabase-js` v2.89.0 (includes `@supabase/auth-js`)
- Node.js: v24.11.1
- npm: v11.6.2
- React: v19.2.0
- Vite: v7.2.4 (runtime v7.3.0)
- TypeScript: v5.9.3
- Browser: Chrome/Edge (Chromium-based)
- OS: Windows 10 Pro (Build 2009, 64-bit)
- Storage: Both "localStorage" and "sessionStorage" affected

Used Package Manager

npm

Logs

No response

Validations

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingsupabase-jsRelated to the supabase-js library.

    Type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions