📋Field ManualAI
2026-01-29authdebuggingnext.js

Debugging Three Auth Layers at Once

A Next.js application with three independent security layers — JWT middleware, edge runtime token handling, and database row-level security — where each layer silently failed in a different way. The kind of bug that makes you question your career choices.

Lesson Learned

When debugging multi-layer auth, never assume one layer is working because another layer passed. Each layer validates different things. A request can pass middleware JWT checks, survive edge runtime token refresh, and still get silently blocked by database row-level security. Debug each layer independently with its own test harness before integrating.

The Setup

The application was a standard Next.js app with a common enterprise auth architecture:

  • Layer 1: JWT Middleware — validates tokens on incoming requests, redirects unauthenticated users to login
  • Layer 2: Edge Runtime — handles token refresh, injects user context into server components, runs at the CDN edge
  • Layer 3: Database RLS — row-level security policies ensuring users can only access their own data, enforced at the database level

Each layer was implemented by a different team member at a different time. Each worked perfectly in isolation. Together, they created a debugging nightmare.

The Symptom

Users reported intermittent "empty dashboard" states. They could log in successfully — the login page worked, the redirect worked, the dashboard rendered — but their data was missing. No error messages. No 401s. No 403s. Just... empty tables.

The symptom was intermittent: it happened roughly 30% of the time, more often after the user had been idle for a while. Refreshing the page sometimes fixed it.

The Investigation

Layer 1: JWT Middleware — The False Positive

First instinct: the middleware isn't blocking unauthenticated requests properly. Investigation revealed the opposite problem — the middleware was too permissive.

// middleware.ts (simplified)
export function middleware(request: NextRequest) {
  const token = request.cookies.get("auth-token")?.value;

  if (!token) {
    return NextResponse.redirect(new URL("/login", request.url));
  }

  // ⚠️ Problem: Only checks token EXISTS, not that it's VALID
  return NextResponse.next();
}

The middleware checked for token presence but not token validity. An expired JWT still had the cookie set, so middleware waved it through. Users with expired tokens passed Layer 1 but failed downstream.

Fix: Decode and validate the JWT in middleware, checking expiry, issuer, and signature:

// middleware.ts (fixed)
export async function middleware(request: NextRequest) {
  const token = request.cookies.get("auth-token")?.value;

  if (!token) {
    return NextResponse.redirect(new URL("/login", request.url));
  }

  try {
    const payload = await verifyJWT(token, process.env.JWT_SECRET!);

    // Check expiry with 30-second buffer
    if (payload.exp && payload.exp < Date.now() / 1000 - 30) {
      return NextResponse.redirect(new URL("/login", request.url));
    }

    // Forward user context to downstream layers
    const response = NextResponse.next();
    response.headers.set("x-user-id", payload.sub);
    return response;
  } catch {
    // Invalid token structure — clear it and redirect
    const response = NextResponse.redirect(
      new URL("/login", request.url)
    );
    response.cookies.delete("auth-token");
    return response;
  }
}

Layer 2: Edge Runtime — The Silent Refresh Failure

With middleware fixed, expired tokens now correctly redirected to login. But a new symptom appeared: users with almost-expired tokens (within 5 minutes of expiry) saw empty dashboards even though middleware passed them through.

The edge runtime was supposed to refresh tokens transparently. But the refresh logic had a race condition:

// Token refresh in server component (simplified)
async function getSession() {
  const token = cookies().get("auth-token")?.value;
  const payload = decodeJWT(token);

  if (isNearExpiry(payload)) {
    // ⚠️ Problem: refresh is async, but component doesn't await it
    // The old token is used for the database query
    refreshTokenInBackground(token);
  }

  // This runs with the OLD (near-expired) token
  return createDatabaseClient(token);
}

The token refresh was firing in the background, but the database client was created with the old token. By the time the query reached the database, the token had expired. The database silently returned empty results instead of an error.

Fix: Await the refresh before creating the database client, and use the new token:

// Token refresh (fixed)
async function getSession() {
  const token = cookies().get("auth-token")?.value;
  const payload = decodeJWT(token);

  let activeToken = token;

  if (isNearExpiry(payload)) {
    try {
      const newToken = await refreshToken(token);
      activeToken = newToken;
      // Set the new cookie for subsequent requests
      cookies().set("auth-token", newToken, {
        httpOnly: true,
        secure: true,
        sameSite: "lax",
      });
    } catch {
      // Refresh failed — redirect to login
      redirect("/login");
    }
  }

  return createDatabaseClient(activeToken);
}

Layer 3: Database RLS — The Invisible Wall

Even with middleware and edge runtime fixed, a subset of queries still returned empty results. The database's row-level security was the final culprit.

The RLS policies were configured to use a session-level function that extracted the user ID from the JWT:

-- RLS policy (simplified)
CREATE POLICY "users_own_data" ON projects
  FOR SELECT
  USING (owner_id = current_user_id());

-- The current_user_id() function reads from the JWT
-- set via: SET LOCAL request.jwt.claims = '...';

The problem: the database client library was setting the JWT claims at connection time, but the connection pool was reusing connections across different users. User A's JWT claims persisted on a pooled connection that User B later inherited.

For User B, two things could happen:

  • They'd see User A's data (security breach) — this was rare because the pool usually reset
  • They'd see no data because the stale claims didn't match their user ID — this was the common case

Fix: Set JWT claims per-query in a transaction, not per-connection:

// Database client (fixed)
async function queryWithRLS(token: string, query: string) {
  const client = await pool.connect();
  try {
    await client.query("BEGIN");
    // Set claims for THIS transaction only
    await client.query(
      "SELECT set_config('request.jwt.claims', $1, true)",
      [token]
    );
    const result = await client.query(query);
    await client.query("COMMIT");
    return result;
  } catch (err) {
    await client.query("ROLLBACK");
    throw err;
  } finally {
    client.release();
  }
}

The Timeline

PhaseTime SpentWhat We ThoughtWhat It Actually Was
Initial triage2 hours"The API is returning empty arrays"Three layers each failing differently
Middleware fix1 hour"Fixed! Expired tokens now redirect"Only fixed one of three failure modes
Edge runtime fix3 hours"Race condition in token refresh"Correct, but not the whole story
RLS discovery4 hours"Why are queries returning empty?"Connection pool leaking JWT claims
Total~10 hoursAcross two developers over three days

What We Should Have Done First

The correct debugging approach — which we eventually stumbled into — was to test each layer independently:

  1. Middleware: curl with expired token → should redirect (it didn't)
  2. Edge runtime: log the token used for the database query → should be fresh (it was stale)
  3. Database: query directly with RLS enabled and a known user token → should return data (it didn't for pooled connections)

If we had built these three test cases upfront, we'd have found all three bugs in an hour instead of ten.

The Debugging Checklist

For any multi-layer auth system, test each of these independently:

  • Expired token — does middleware correctly reject or redirect?
  • Near-expiry token — does token refresh complete before downstream use?
  • Valid token, wrong user — does RLS correctly restrict data access?
  • Pooled connections — do JWT claims reset between requests?
  • Missing token — does every layer handle null gracefully?
  • Malformed token — does decoding fail safely, not crash?
  • Concurrent requests — does token refresh handle race conditions?

Prevention: The Auth Audit Script

After this incident, we added an automated auth health check that runs in CI:

// auth-health.test.ts (simplified)
describe("Auth layer integration", () => {
  it("rejects expired tokens at middleware", async () => {
    const expiredToken = createTestJWT({ exp: past() });
    const res = await fetch("/dashboard", {
      headers: { cookie: `auth-token=${expiredToken}` },
      redirect: "manual",
    });
    expect(res.status).toBe(307);
    expect(res.headers.get("location")).toContain("/login");
  });

  it("refreshes near-expiry tokens before DB query", async () => {
    const nearExpiryToken = createTestJWT({
      exp: Date.now() / 1000 + 60,
    });
    const res = await fetch("/api/projects", {
      headers: { cookie: `auth-token=${nearExpiryToken}` },
    });
    expect(res.status).toBe(200);
    const data = await res.json();
    expect(data.length).toBeGreaterThan(0);
  });

  it("isolates RLS claims per request", async () => {
    // Make two requests as different users on same connection pool
    const [resA, resB] = await Promise.all([
      fetchAs(userAToken, "/api/projects"),
      fetchAs(userBToken, "/api/projects"),
    ]);
    const dataA = await resA.json();
    const dataB = await resB.json();
    // Each should only see their own projects
    expect(dataA.every((p: any) => p.owner_id === userA.id)).toBe(true);
    expect(dataB.every((p: any) => p.owner_id === userB.id)).toBe(true);
  });
});

FAQ

Why do Next.js auth systems fail silently across multiple layers?

Multi-layer auth systems fail silently because each layer handles authentication independently with its own error modes. JWT middleware may validate token structure without checking expiry. The edge runtime may create database clients with stale tokens. Database RLS may deny access by returning empty results instead of errors. Each layer assumes the others are working, creating gaps where requests pass one layer but fail silently at another.

How do you debug JWT middleware issues in Next.js edge runtime?

Debug systematically: (1) Log raw request headers at middleware entry to confirm cookies arrive. (2) Decode the JWT manually to check claims, expiry, and issuer. (3) Verify the edge runtime has access to the JWT secret — environment variables may differ between edge and Node.js runtimes. (4) Check middleware matcher patterns — Next.js can silently exclude paths. (5) Test with a hardcoded valid token to isolate token generation from token validation.

What causes row-level security to silently block queries?

RLS silently blocks when: the database connection uses a role that doesn't match RLS policies, the JWT claims don't contain the expected user ID field, the auth function returns null because the token wasn't set in the database session, or — the hardest to find — connection pools leak JWT claims between users. RLS policies use restrictive defaults where rows become invisible rather than returning permission errors.