This error occurs when a Supabase auth hook exceeds its execution time limit - 2 seconds for Postgres hooks or 5 seconds for HTTP hooks. The authentication flow is interrupted to prevent delays.
Supabase auth hooks are designed to run quickly during authentication flows to avoid slowing down user sign-in and sign-up operations. When you configure an auth hook (either a Postgres function or HTTP webhook), Supabase enforces strict timeout limits: Postgres hooks must complete within 2 seconds, while HTTP hooks have a 5-second window. When a hook exceeds these limits, Supabase returns a hook_timeout error and cancels the authentication operation. This timeout is a safety mechanism to ensure that slow or hung hooks don't block users from accessing your application. Both Postgres and HTTP hooks run within database transactions, which limits their execution duration and ensures database consistency. The timeout cannot be configured or extended - it's a hard limit in Supabase's authentication system. This means you must design your hooks to be lightweight and fast-executing, avoiding any operations that could take longer than the allowed timeframe.
Check your Supabase dashboard to see which hooks are configured:
# Navigate to Authentication > Hooks in Supabase Dashboard
# Or check your supabase/config.toml fileLook for hooks like:
- Custom Access Token Hook (runs on JWT refresh)
- Before User Created Hook (runs on sign-up)
- Send Email Hook (runs when sending auth emails)
- Send SMS Hook (runs when sending auth SMS)
Check your logs to see which hook type is failing.
Remove any heavy operations from your hook:
For HTTP Hooks (5 second limit):
// ❌ BAD - Makes external API call
export async function POST(request: Request) {
const { user } = await request.json();
// This could timeout!
await supabase.auth.admin.updateUserById(user.id, {
app_metadata: { role: 'user' }
});
return Response.json({ user });
}
// ✅ GOOD - Lightweight processing only
export async function POST(request: Request) {
const { user } = await request.json();
// Just add claims, don't make API calls
return Response.json({
user: {
...user,
app_metadata: {
...user.app_metadata,
role: 'user'
}
}
});
}For Postgres Hooks (2 second limit):
-- ❌ BAD - Complex queries in hook
CREATE OR REPLACE FUNCTION custom_access_token_hook(event jsonb)
RETURNS jsonb AS $$
BEGIN
-- Heavy query could timeout
SELECT * FROM large_table WHERE user_id = (event->>'user_id')::uuid;
RETURN event;
END;
$$ LANGUAGE plpgsql;
-- ✅ GOOD - Minimal processing
CREATE OR REPLACE FUNCTION custom_access_token_hook(event jsonb)
RETURNS jsonb AS $$
BEGIN
-- Simple, fast operation
RETURN jsonb_set(event, '{claims,role}', '"user"');
END;
$$ LANGUAGE plpgsql;Use database triggers or background jobs instead of auth hooks for heavy operations:
Option 1: Database Trigger (runs after auth completes)
-- Create a trigger that runs AFTER user creation
CREATE OR REPLACE FUNCTION handle_new_user()
RETURNS trigger AS $$
BEGIN
-- This runs outside the auth transaction
INSERT INTO public.profiles (id, email)
VALUES (NEW.id, NEW.email);
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER on_auth_user_created
AFTER INSERT ON auth.users
FOR EACH ROW EXECUTE FUNCTION handle_new_user();Option 2: Client-side post-auth processing
// Handle heavy operations after successful auth
const { data, error } = await supabase.auth.signUp({
email: '[email protected]',
password: 'password123'
});
if (data.user) {
// Now make your API calls outside the auth flow
await fetch('/api/setup-user-profile', {
method: 'POST',
body: JSON.stringify({ userId: data.user.id })
});
}Option 3: Queue background job
// In your auth hook, just queue a job
export async function POST(request: Request) {
const { user } = await request.json();
// Fast operation - just queue for later
await queue.publish('user-created', { userId: user.id });
return Response.json({ user });
}Never call supabase.auth.admin methods from within an auth hook as this creates circular dependencies:
// ❌ NEVER DO THIS in an auth hook
export async function POST(request: Request) {
const { user } = await request.json();
// This will always timeout because the auth transaction is locked
await supabase.auth.admin.updateUserById(user.id, {
app_metadata: { onboarded: true }
});
return Response.json({ user });
}
// ✅ Instead, modify the returned object directly
export async function POST(request: Request) {
const { user } = await request.json();
// Modify the user object being returned
const updatedUser = {
...user,
app_metadata: {
...user.app_metadata,
onboarded: true
}
};
return Response.json({ user: updatedUser });
}For Postgres hooks, modify the NEW object directly instead of making separate UPDATE queries.
If using HTTP hooks, ensure your webhook endpoint responds quickly:
// Deploy as Edge Function for low latency
import { serve } from 'https://deno.land/[email protected]/http/server.ts';
serve(async (req) => {
// Set aggressive timeout
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 4000); // 4s max
try {
const { user } = await req.json();
// Fast, synchronous processing only
const claims = {
role: user.email?.endsWith('@admin.com') ? 'admin' : 'user'
};
clearTimeout(timeoutId);
return new Response(JSON.stringify({ user, claims }), {
headers: { 'Content-Type': 'application/json' }
});
} catch (error) {
clearTimeout(timeoutId);
// Return user unchanged on error
return new Response(JSON.stringify({ user: req.user }), {
status: 200,
headers: { 'Content-Type': 'application/json' }
});
}
});Performance checklist:
- Deploy webhook as Edge Function (not serverless with cold starts)
- Avoid database queries in webhook
- Don't call external APIs
- Return early on errors
- Use in-memory operations only
If using Postgres hooks, check for lock contention:
-- Check for locks in auth schema
SELECT
pid,
usename,
pg_blocking_pids(pid) as blocked_by,
query as blocked_query
FROM pg_stat_activity
WHERE
(pg_blocking_pids(pid)::text != '{}')
AND query LIKE '%auth%';
-- Check long-running transactions
SELECT
pid,
now() - pg_stat_activity.query_start AS duration,
query,
state
FROM pg_stat_activity
WHERE state != 'idle'
AND now() - pg_stat_activity.query_start > interval '1 second'
ORDER BY duration DESC;If you see locks, optimize your database triggers or remove conflicting operations.
Time your hook execution to ensure it stays under limits:
// Test HTTP hook locally
async function testHook() {
const startTime = Date.now();
const response = await fetch('http://localhost:54321/functions/v1/auth-hook', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
user: { id: 'test-id', email: '[email protected]' }
})
});
const duration = Date.now() - startTime;
console.log(`Hook took ${duration}ms`);
if (duration > 4000) {
console.warn('⚠️ Hook is too slow! Must be under 5000ms');
}
}For Postgres hooks:
-- Enable timing
\timing on
-- Test your hook
SELECT custom_access_token_hook('{"user_id": "test-id"}'::jsonb);
-- Should complete in under 2000msIf you need to quickly restore authentication while debugging:
Via Dashboard:
1. Go to Authentication > Hooks
2. Click on the failing hook
3. Toggle it off or delete it
Via config file:
# supabase/config.toml
[auth.hook.custom_access_token]
enabled = false
# uri = "..."Apply changes:
supabase db pushThis immediately restores normal authentication while you fix the hook.
Hook Type-Specific Timeout Behaviors:
Different auth hooks have different performance characteristics:
- Custom Access Token Hook: Runs on every JWT refresh (potentially every few minutes). This is the most performance-critical hook - avoid ANY database queries or external calls.
- Before User Created Hook: Runs once per user sign-up. Has slightly more tolerance for processing but still must stay under limits.
- Send Email/SMS Hooks: Most prone to timeouts due to email provider latency. Consider using Supabase's built-in email service or ensure your provider has SLA guarantees under 3 seconds.
Database Transaction Implications:
Both Postgres and HTTP hooks run within the auth database transaction. This means:
1. Any data modifications in your hook are rolled back if the hook times out
2. The auth.users table is locked during hook execution
3. Concurrent sign-ups can create lock contention if hooks are slow
4. ShareLock waits in Postgres logs indicate transaction blocking
Monitoring and Observability:
Set up monitoring for auth hook performance:
// Add timing headers to HTTP hooks
export async function POST(request: Request) {
const startTime = Date.now();
// Your hook logic
const result = processHook(request);
const duration = Date.now() - startTime;
return new Response(JSON.stringify(result), {
headers: {
'X-Hook-Duration': duration.toString(),
'Content-Type': 'application/json'
}
});
}Monitor these metrics to catch performance degradation before it causes timeouts.
Serverless Cold Starts:
If your HTTP hook is deployed on a serverless platform (AWS Lambda, Vercel Functions), cold starts can easily exceed the 5-second limit. Solutions:
- Use Supabase Edge Functions (no cold starts, globally distributed)
- Implement provisioned concurrency on AWS Lambda
- Keep functions warm with scheduled pings
- Or better yet, avoid external HTTP calls entirely by using Postgres hooks
email_address_not_authorized: Email sending to this address is not authorized
Email address not authorized for sending in Supabase Auth
reauthentication_needed: Reauthentication required for security-sensitive actions
Reauthentication required for security-sensitive actions
no_authorization: No authorization header was provided
How to fix "no authorization header was provided" in Supabase
otp_expired: OTP has expired
How to fix 'otp_expired: OTP has expired' in Supabase
bad_oauth_state: OAuth state parameter is missing or invalid
How to fix 'bad_oauth_state: OAuth state parameter missing' in Supabase