Skip to content

Commit aa7bc8e

Browse files
perf(auth): eliminate all unnecessary auth API calls in middleware
Three optimizations that should reduce auth requests from 13K+/hr to near-zero for unauthenticated traffic: 1. Public routes with no auth cookies: skip updateSession() entirely (zero API calls) 2. Parse JWT role from cookie directly instead of creating a second Supabase client 3. Remove refreshSession() from tenant sync — it created an infinite retry loop when rate-limited. Now fire-and-forget the app_metadata update; JWT picks up new tenant_id on the next natural token refresh. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 5d54650 commit aa7bc8e

1 file changed

Lines changed: 96 additions & 68 deletions

File tree

proxy.ts

Lines changed: 96 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -215,70 +215,63 @@ export default async function proxy(request: NextRequest) {
215215
normalizedPath === route || normalizedPath.startsWith(route + '/')
216216
)
217217

218-
// Update session — validates token server-side (1 auth API call) and returns the user.
219-
// Re-using the user here avoids a second getUser() network call.
220-
const { response: supabaseResponse, user } = await updateSession(request)
221-
222-
// Set tenant + user ID headers on the response for downstream server components.
223-
// This lets server components read the user ID from headers instead of calling
224-
// getUser() again (which is a network call to Supabase Auth on every invocation).
218+
// --- Public routes: skip auth entirely when no cookies ---
225219
intlResponse.headers.set('x-tenant-id', tenantId)
220+
const hasAuthCookies = request.cookies.getAll().some(c => c.name.startsWith('sb-'))
221+
222+
if (isPublicRoute && !hasAuthCookies) {
223+
// Fast path: unauthenticated user on public page — zero auth API calls
224+
return intlResponse
225+
}
226+
227+
// --- Auth session validation (1 auth API call via getUser()) ---
228+
// Only runs when auth cookies exist (skip for bots, crawlers, unauthenticated visitors)
229+
const { response: supabaseResponse, user } = await updateSession(request)
226230
supabaseResponse.headers.set('x-tenant-id', tenantId)
231+
232+
// Set user ID header so server components can read it without calling getUser() again
227233
if (user) {
228234
request.headers.set('x-user-id', user.id)
229235
intlResponse.headers.set('x-user-id', user.id)
230236
supabaseResponse.headers.set('x-user-id', user.id)
231237
}
232238

233-
// Single Supabase client used for the rest of this middleware run.
234-
// getSession() reads from cookie (no network call) — safe because updateSession
235-
// already validated the token server-side above.
236-
const supabase = createServerClient(
237-
process.env.NEXT_PUBLIC_SUPABASE_URL!,
238-
process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_OR_ANON_KEY!,
239-
{
240-
cookies: {
241-
getAll() { return request.cookies.getAll() },
242-
setAll(cookiesToSet) {
243-
// Propagate any cookie writes (e.g. from refreshSession) to the response
244-
const cookieDomain = (() => {
245-
const d = process.env.NEXT_PUBLIC_PLATFORM_DOMAIN?.split(':')[0]
246-
if (!d || d === 'localhost' || d === '127.0.0.1') return undefined
247-
return `.${d}`
248-
})()
249-
cookiesToSet.forEach(({ name, value }) => request.cookies.set(name, value))
250-
cookiesToSet.forEach(({ name, value, options }) =>
251-
supabaseResponse.cookies.set(name, value, {
252-
...options,
253-
...(cookieDomain ? { domain: cookieDomain } : {}),
254-
})
255-
)
256-
},
257-
},
258-
}
259-
)
260-
261-
let session = null
239+
// Read JWT claims from cookie (no network call) — getSession() is a local read
240+
let userRole: 'student' | 'teacher' | 'admin' = 'student'
262241
if (user) {
263242
try {
264-
const { data: sessionData } = await supabase.auth.getSession()
265-
session = sessionData.session
243+
// Parse JWT directly from cookie to avoid creating another Supabase client
244+
const authCookie = request.cookies.getAll().find(c => c.name.startsWith('sb-') && c.name.endsWith('-auth-token'))
245+
if (authCookie) {
246+
const sessionData = JSON.parse(authCookie.value)
247+
const accessToken = sessionData?.access_token || sessionData?.[0]?.access_token
248+
if (accessToken) {
249+
const payload = JSON.parse(atob(accessToken.split('.')[1]))
250+
userRole = payload.tenant_role || payload.user_role || 'student'
251+
}
252+
}
266253
} catch {
267-
session = null
268-
}
269-
}
270-
271-
let userRole: 'student' | 'teacher' | 'admin' = 'student'
272-
if (session?.access_token) {
273-
try {
274-
const payload = JSON.parse(atob(session.access_token.split('.')[1]))
275-
userRole = payload.tenant_role || payload.user_role || 'student'
276-
} catch (e) {
277-
// ignore
254+
// Fallback: try chunked cookies (sb-*-auth-token.0, .1, etc.)
255+
try {
256+
const chunks = request.cookies.getAll()
257+
.filter(c => c.name.match(/^sb-.*-auth-token\.\d+$/))
258+
.sort((a, b) => a.name.localeCompare(b.name))
259+
if (chunks.length > 0) {
260+
const combined = chunks.map(c => c.value).join('')
261+
const sessionData = JSON.parse(combined)
262+
const accessToken = sessionData?.access_token
263+
if (accessToken) {
264+
const payload = JSON.parse(atob(accessToken.split('.')[1]))
265+
userRole = payload.tenant_role || payload.user_role || 'student'
266+
}
267+
}
268+
} catch {
269+
// ignore — default to 'student'
270+
}
278271
}
279272
}
280273

281-
// Auth Guards
274+
// Auth Guards — public routes
282275
if (isPublicRoute) {
283276
if (user && (normalizedPath.startsWith('/auth/login') || normalizedPath.startsWith('/auth/sign-up'))) {
284277
const dashboardUrl = new URL(`/${locale}/dashboard/${userRole}`, request.url)
@@ -299,6 +292,31 @@ export default async function proxy(request: NextRequest) {
299292
return NextResponse.redirect(redirectUrl)
300293
}
301294

295+
// Supabase client for DB queries (tenant_users check) — no auth API calls
296+
const supabase = createServerClient(
297+
process.env.NEXT_PUBLIC_SUPABASE_URL!,
298+
process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_OR_ANON_KEY!,
299+
{
300+
cookies: {
301+
getAll() { return request.cookies.getAll() },
302+
setAll(cookiesToSet) {
303+
const cookieDomain = (() => {
304+
const d = process.env.NEXT_PUBLIC_PLATFORM_DOMAIN?.split(':')[0]
305+
if (!d || d === 'localhost' || d === '127.0.0.1') return undefined
306+
return `.${d}`
307+
})()
308+
cookiesToSet.forEach(({ name, value }) => request.cookies.set(name, value))
309+
cookiesToSet.forEach(({ name, value, options }) =>
310+
supabaseResponse.cookies.set(name, value, {
311+
...options,
312+
...(cookieDomain ? { domain: cookieDomain } : {}),
313+
})
314+
)
315+
},
316+
},
317+
}
318+
)
319+
302320
// Check if user is a member of the current tenant and get their tenant role
303321
if (!normalizedPath.startsWith('/join-school')) {
304322
const { data: membership } = await supabase
@@ -310,7 +328,6 @@ export default async function proxy(request: NextRequest) {
310328
.single()
311329

312330
if (!membership) {
313-
// User is not a member of this tenant - redirect to join page
314331
const joinUrl = new URL(`/${locale}/join-school`, request.url)
315332
return NextResponse.redirect(joinUrl)
316333
}
@@ -321,30 +338,41 @@ export default async function proxy(request: NextRequest) {
321338
}
322339

323340
// Sync app_metadata.tenant_id if it doesn't match the current subdomain tenant.
324-
// This ensures get_tenant_id() in RLS policies returns the correct tenant.
325-
const jwtTenantId = session?.access_token
326-
? (() => { try { return JSON.parse(atob(session.access_token.split('.')[1])).tenant_id } catch { return null } })()
327-
: null
328-
329-
if (jwtTenantId !== tenantId) {
330-
// Update user's app_metadata so the next JWT refresh includes the correct tenant_id
331-
try {
332-
await fetch(`${SUPABASE_URL}/auth/v1/admin/users/${user.id}`, {
341+
// Only update app_metadata via admin API (no refreshSession — that burns rate limits).
342+
// The JWT will pick up the new tenant_id on the next natural token refresh.
343+
try {
344+
const authCookie = request.cookies.getAll().find(c => c.name.startsWith('sb-') && c.name.endsWith('-auth-token'))
345+
const chunks = request.cookies.getAll()
346+
.filter(c => c.name.match(/^sb-.*-auth-token\.\d+$/))
347+
.sort((a, b) => a.name.localeCompare(b.name))
348+
let accessToken: string | null = null
349+
if (authCookie) {
350+
const sd = JSON.parse(authCookie.value)
351+
accessToken = sd?.access_token || sd?.[0]?.access_token
352+
} else if (chunks.length > 0) {
353+
const sd = JSON.parse(chunks.map(c => c.value).join(''))
354+
accessToken = sd?.access_token
355+
}
356+
const jwtTenantId = accessToken
357+
? JSON.parse(atob(accessToken.split('.')[1])).tenant_id
358+
: null
359+
360+
if (jwtTenantId !== tenantId) {
361+
// Fire-and-forget: update app_metadata so the NEXT token refresh gets correct tenant_id.
362+
// Do NOT call refreshSession() — it makes another auth API call and if rate-limited
363+
// it creates an infinite retry loop on every request.
364+
fetch(`${SUPABASE_URL}/auth/v1/admin/users/${user.id}`, {
333365
method: 'PUT',
334366
headers: {
335367
apikey: SUPABASE_SERVICE_ROLE_KEY,
336368
Authorization: `Bearer ${SUPABASE_SERVICE_ROLE_KEY}`,
337369
'Content-Type': 'application/json',
338370
},
339-
body: JSON.stringify({
340-
app_metadata: { tenant_id: tenantId },
341-
}),
342-
})
343-
// Refresh the session so the JWT gets updated claims immediately
344-
await supabase.auth.refreshSession()
345-
} catch {
346-
// Non-blocking — next request will retry
371+
body: JSON.stringify({ app_metadata: { tenant_id: tenantId } }),
372+
}).catch(() => {}) // Non-blocking
347373
}
374+
} catch {
375+
// JWT parsing failed — skip tenant sync
348376
}
349377
}
350378

0 commit comments

Comments
 (0)