Returning 401 Responses from Edge Middleware

This guide is part of Implementing Early Returns in Edge Middleware. It shows how to construct the rejection response itself — the status line, the headers that make it correct, the body that gives nothing away, and the branch that decides whether a browser sees a login page or a JSON error.

Most authentication work at the edge goes into the verification: parsing the credential, checking the signature, validating claims. The rejection gets one line — return new Response('Unauthorized', { status: 401 }) — and that line is where the bugs live. It is missing a header the specification calls mandatory, it is cacheable by a shared tier that will happily replay it to your logged-in users, it tells an attacker exactly which part of their forged token failed, and it hands a browser a wall of plain text where a login redirect belonged.

The problem

Three symptoms show up, usually in this order.

A client library stops retrying. You return 401 from middleware, and the SDK on the other end — anything implementing the standard challenge-response flow — sees no WWW-Authenticate header, concludes there is no authentication scheme on offer, and gives up instead of refreshing its token. The request never comes back.

Then a support ticket says a user was randomly signed out. They were authenticated, they loaded a page, and they got a 401 they had no way to clear except a hard refresh. What happened is that a 401 produced for an unauthenticated request was stored by a shared cache tier under a cache key that did not include the credential, and it was replayed to the next person who asked for the same URL.

Finally, a penetration test flags credential enumeration. Your rejection body helpfully distinguishes token expired from invalid signature from unknown key id, and that distinction is a free oracle: an attacker probing forged tokens can tell which of their guesses were structurally correct and which failed on the signature alone.

Root cause: 401 is a protocol handshake, not an error message

A 401 is not “you are not allowed.” It is the server half of a challenge-response exchange defined by HTTP: the server says this resource requires authentication, and here is the scheme to use, and the client is expected to retry with credentials. That is why WWW-Authenticate is a required header on every 401 — without it the response is a protocol violation, and a well-behaved client has nothing to act on.

403 is the other half of the distinction and it is not a handshake at all. 403 Forbidden means the server understood who you are and the answer is still no. Retrying with the same credentials will not help, and a challenge header would be meaningless. The rule is mechanical: if a different or refreshed credential could plausibly change the outcome, return 401; if the identity is established and merely insufficient, return 403.

The caching hazard follows from the same shape. A 401 is a property of the request’s credential, not of the URL. Any cache that keys on the URL alone — a CDN tier, a corporate forward proxy, a browser’s shared HTTP cache — will treat that response as a property of the resource. The default heuristics in RFC 9111 permit caching a 401 when explicit freshness is present, and several intermediaries will store it regardless if you do not forbid it. Cache-Control: no-store is the only instruction that reliably prevents the response from being written down at all.

Choosing the rejection status A decision tree starts from the incoming request and branches on whether a credential is present, whether it verifies, and whether it grants the required scope. The three leaves are 401 with a challenge, 403 forbidden, and continue to origin. Request at the matcher Credential present and verifies? Scope sufficient for this route? 401 + challenge retry may succeed 403 retry cannot help Continue forward to origin no yes no yes
Only the first branch is about authentication. Everything past it is authorization, and authorization failures are never a 401.

Step 1 — Build the challenge header

WWW-Authenticate names the scheme and, optionally, carries structured detail about why the token was rejected. For bearer tokens RFC 6750 defines three parameters: realm, error, and error_description. The temptation is to fill all three. Resist it for anything except the two error codes that are safe to disclose.

error="invalid_request" says the request was malformed — no token where one was required, or two conflicting credentials. error="invalid_token" says a token was presented and did not pass. Those two are the entire vocabulary you should emit. They tell a legitimate client whether to prompt for login or to refresh silently, and they tell an attacker nothing they did not already know from the status code.

// lib/challenge.ts
export type RejectKind = 'no_credential' | 'bad_credential';

const REALM = 'api';

/**
 * Build the WWW-Authenticate value. Deliberately narrow: the caller never
 * learns whether the token expired, was signed by the wrong key, or carried
 * the wrong audience. That detail belongs in logs, not on the wire.
 */
export function challenge(kind: RejectKind): string {
  if (kind === 'no_credential') {
    // No token at all: a bare challenge, no error parameter. RFC 6750 says
    // omit `error` when the request carried no authentication attempt.
    return `Bearer realm="${REALM}"`;
  }
  return `Bearer realm="${REALM}", error="invalid_token", error_description="The access token is invalid"`;
}

The fixed error_description string is intentional. It is constant across expiry, signature failure, audience mismatch and unknown key id, so its presence carries zero bits of information — but it keeps SDKs that log the description from logging an empty field.

Step 2 — Decide between an API rejection and a login redirect

A browser navigating to a protected page and a fetch call from that same page want completely different responses to the same authentication failure. The browser wants to end up at the login screen. The fetch wants a status code it can branch on, and if you redirect it, the redirect is followed transparently and it receives an HTML login page with status 200 — which is how “my JSON parser crashed on <!DOCTYPE” tickets are born.

Content negotiation resolves it, but not on Accept alone. The signal that actually distinguishes the two cases is Sec-Fetch-Mode: navigate, sent by every current browser on a top-level navigation and never on a programmatic fetch. Use it first and fall back to Accept for older clients and non-browser agents.

// lib/negotiate.ts
export type Channel = 'navigation' | 'api';

export function channelFor(request: Request): Channel {
  // Only a document navigation should ever be redirected to a login page.
  const mode = request.headers.get('sec-fetch-mode');
  if (mode) return mode === 'navigate' ? 'navigation' : 'api';

  // Fallback for clients that do not send Sec-Fetch-*: an explicit JSON
  // preference, or an XHR marker, means API. Anything asking for HTML first
  // is treated as a navigation.
  const accept = request.headers.get('accept') ?? '';
  if (request.headers.get('x-requested-with') === 'XMLHttpRequest') return 'api';
  if (accept.includes('application/json')) return 'api';
  return accept.includes('text/html') ? 'navigation' : 'api';
}

Defaulting the unknown case to api is the safe direction. A misclassified navigation shows a JSON body, which is ugly; a misclassified fetch receives an HTML login page under a 200, which is a silent data-corruption bug in every consumer.

Anatomy of a correct 401 A rendered HTTP response is annotated line by line. The status line, WWW-Authenticate, Cache-Control, Vary and Content-Type each carry a note explaining what breaks when the line is absent. HTTP/1.1 401 Unauthorized WWW-Authenticate: Bearer realm="api" Cache-Control: no-store Vary: Authorization, Cookie Content-Type: application/json {"error":"unauthorized"} not 403 — a new credential could work mandatory; clients retry off this stops a shared tier replaying it belt and braces if a tier ignores no-store chosen by content negotiation identical for every failure reason
Every line is load-bearing. Drop the challenge and clients stop retrying; drop no-store and the rejection outlives the request that caused it.

Step 3 — Assemble the response

One factory function builds every rejection the application can emit. Centralising it is what stops a second 401 appearing somewhere else in the chain with half the headers.

// lib/unauthorized.ts
import { challenge, type RejectKind } from './challenge';
import { channelFor } from './negotiate';

export interface RejectOptions {
  kind: RejectKind;
  /** Where a browser should be sent to sign in. */
  loginPath?: string;
}

export function unauthorized(request: Request, opts: RejectOptions): Response {
  const headers = new Headers({
    'www-authenticate': challenge(opts.kind),
    // The single most important line: this response describes the request's
    // credential, not the resource, so nothing may ever store it.
    'cache-control': 'no-store, private',
    // Belt and braces for tiers that honour Vary but ignore no-store.
    vary: 'Authorization, Cookie, Sec-Fetch-Mode',
    'x-content-type-options': 'nosniff',
    'referrer-policy': 'no-referrer',
  });

  if (channelFor(request) === 'navigation') {
    const url = new URL(request.url);
    const login = new URL(opts.loginPath ?? '/login', url.origin);
    // Round-trip the user back to where they were, path only — never an
    // absolute URL from user input, which is an open-redirect.
    login.searchParams.set('next', url.pathname + url.search);
    headers.set('location', login.toString());
    // 303 forces the retry to be a GET even if the rejected request was a POST.
    return new Response(null, { status: 303, headers });
  }

  headers.set('content-type', 'application/json; charset=utf-8');
  // One body for every failure. No reason, no hint, no timing difference.
  return new Response(JSON.stringify({ error: 'unauthorized' }), {
    status: 401,
    headers,
  });
}

Two details deserve calling out. The next parameter is built from url.pathname + url.search and never from a caller-supplied absolute URL, because accepting one turns your login page into an open redirect that phishing campaigns will find within days. And the navigation branch returns 303, not 302: a rejected POST retried as a POST against the login page is nonsense, and 303 is the status that mandates the method change.

The API body is a constant. Not a template, not an interpolation of the failure reason, not a request id that varies in length. The reason goes to structured logs where your on-call engineer can read it, as described in structured logging for edge functions.

Step 4 — Wire the early return into the chain

// middleware.ts
import { verify } from './lib/verify';
import { unauthorized } from './lib/unauthorized';

export default async function middleware(request: Request): Promise<Response> {
  const header = request.headers.get('authorization');
  const cookie = request.headers.get('cookie') ?? '';
  const sessionMatch = cookie.match(/(?:^|;\s*)__Host-session=([^;]+)/);

  const token = header?.startsWith('Bearer ')
    ? header.slice(7)
    : sessionMatch
      ? decodeURIComponent(sessionMatch[1])
      : null;

  if (!token) {
    return unauthorized(request, { kind: 'no_credential' });
  }

  const result = await verify(token);
  if (!result.ok) {
    console.log(JSON.stringify({
      event: 'auth.reject',
      reason: result.reason,                 // expired | bad_signature | ...
      path: new URL(request.url).pathname,
      // Never log the token. A hash prefix is enough to correlate retries.
      tokenFingerprint: await fingerprint(token),
    }));
    return unauthorized(request, { kind: 'bad_credential' });
  }

  const forwarded = new Headers(request.headers);
  forwarded.delete('x-user-id');
  forwarded.set('x-user-id', result.identity.userId);
  return fetch(new Request(request, { headers: forwarded }));
}

async function fingerprint(token: string): Promise<string> {
  const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(token));
  return Array.from(new Uint8Array(digest).slice(0, 6))
    .map((b) => b.toString(16).padStart(2, '0'))
    .join('');
}

The x-user-id delete before the set is not paranoia. A client can send x-user-id itself, and if you only call set on a header object built from the original request you are relying on set replacing rather than appending — true for Headers, but the explicit delete documents the intent and survives a refactor to a different header container.

Step 5 — Scope the matcher

export const config = {
  runtime: 'edge',
  matcher: [
    // Protected surfaces only. The login page itself must stay reachable,
    // or an unauthenticated user is redirected in a loop.
    '/((?!login|_next/static|_next/image|favicon.ico|robots.txt|api/public).*)',
  ],
};

Excluding /login is the fix for the single most common deployment failure of this pattern: middleware rejects the anonymous request for /login, redirects it to /login, and the browser gives up after twenty hops. Matcher ordering interacts with this in Next.js; see controlling matcher order in Next.js middleware.

What a cacheable 401 does at a shared tier The upper timeline shows an anonymous request producing a 401 that a shared cache stores and then serves to a later authenticated request. The lower timeline shows the same sequence with no-store, where the authenticated request reaches the origin. Without no-store anonymous GET /me 401 stored at tier signed-in GET /me tier HIT user sees 401 origin never asked With no-store anonymous GET /me 401 not stored signed-in GET /me tier MISS 200 with profile origin consulted
The replay only needs one anonymous request to poison a URL. The window lasts as long as the tier's default heuristic freshness, which is often minutes.

Local versus production divergence

Behaviour Local dev Edge production
Shared cache tier Absent — nothing between you and the handler Present; a cacheable 401 is replayable
Sec-Fetch-Mode Sent by the browser, absent from curl and test fetches Sent by all current browsers, absent from most SDKs
__Host- cookie prefix Rejected over http://localhost in some browsers Works over HTTPS, which is always the case at a PoP
Redirect following fetch follows the 303 and you see the login HTML Identical, but now inside a customer’s SDK
Vary handling Ignored, no cache to vary Honoured by the CDN and any forward proxy
Failure reason visibility Printed to your terminal, feels harmless Goes to a log sink; must never reach the body
Redirect loop on /login Obvious immediately Also obvious, but only once the matcher is deployed

The first row is the one that matters. Nothing in local development will ever reproduce a replayed 401, because there is no shared tier to replay it. The only defence is asserting cache-control in tests, which is exactly what the suite below does.

Validating with Vitest

The rejection factory is a pure function of a Request, so it tests without any runtime emulation.

// unauthorized.test.ts
import { describe, expect, it } from 'vitest';
import { unauthorized } from './lib/unauthorized';

function req(headers: Record<string, string> = {}, url = 'https://app.test/dashboard') {
  return new Request(url, { headers });
}

describe('unauthorized()', () => {
  it('always sets a WWW-Authenticate challenge', async () => {
    const res = unauthorized(req({ accept: 'application/json' }), { kind: 'no_credential' });
    expect(res.status).toBe(401);
    expect(res.headers.get('www-authenticate')).toBe('Bearer realm="api"');
  });

  it('marks the response no-store so no tier can replay it', () => {
    const res = unauthorized(req({ accept: 'application/json' }), { kind: 'bad_credential' });
    expect(res.headers.get('cache-control')).toMatch(/no-store/);
    expect(res.headers.get('vary')).toContain('Authorization');
  });

  it('returns an identical body for every failure kind', async () => {
    const a = await unauthorized(req({ accept: 'application/json' }), { kind: 'no_credential' }).text();
    const b = await unauthorized(req({ accept: 'application/json' }), { kind: 'bad_credential' }).text();
    expect(a).toBe(b);
    expect(a).toBe('{"error":"unauthorized"}');
  });

  it('never leaks the failure reason in the challenge', () => {
    const res = unauthorized(req({ accept: 'application/json' }), { kind: 'bad_credential' });
    const value = res.headers.get('www-authenticate') ?? '';
    for (const leak of ['expired', 'signature', 'audience', 'kid', 'issuer']) {
      expect(value.toLowerCase()).not.toContain(leak);
    }
  });

  it('redirects a document navigation to login with a relative next', () => {
    const res = unauthorized(
      req({ 'sec-fetch-mode': 'navigate', accept: 'text/html' }, 'https://app.test/reports?q=1'),
      { kind: 'no_credential' },
    );
    expect(res.status).toBe(303);
    const location = new URL(res.headers.get('location')!);
    expect(location.pathname).toBe('/login');
    expect(location.searchParams.get('next')).toBe('/reports?q=1');
  });

  it('does not redirect a programmatic fetch', () => {
    const res = unauthorized(
      req({ 'sec-fetch-mode': 'cors', accept: 'text/html,*/*' }),
      { kind: 'bad_credential' },
    );
    expect(res.status).toBe(401);
    expect(res.headers.get('location')).toBeNull();
  });

  it('treats an unknown client as an API caller', () => {
    const res = unauthorized(req({}), { kind: 'no_credential' });
    expect(res.status).toBe(401);
  });
});

The third and fourth tests are the ones that will actually catch a regression. Body-identity and challenge-cleanliness are exactly the properties a well-meaning contributor breaks when they add “just a little more detail to help debugging.” A grep-style assertion against a list of leak words is crude and it works.

Common pitfalls and resolutions

Client SDK never retries after a 401 — no WWW-Authenticate header. The response is a protocol violation and a conforming client has no scheme to retry with. Add the challenge.

Authenticated users see intermittent 401s — the rejection is cacheable and a shared tier replayed it. Add cache-control: no-store, private and Vary: Authorization, Cookie.

JSON parse error on the client saying Unexpected token < — a fetch was redirected to the HTML login page and followed the redirect transparently. Branch on Sec-Fetch-Mode and only redirect real navigations.

Redirect loop on /login — the login route is inside the matcher. Exclude it explicitly.

A 403 where a 401 belonged, or vice versa — you returned 403 for a missing token, so clients that would have refreshed instead surfaced a hard error. If a new credential could change the outcome, it is 401.

Open redirect reported on the login pagenext accepted an absolute URL. Build it from pathname + search only and validate it starts with a single / before using it.

POST retried as POST against the login page — you used 302, which historically permits method preservation. Use 303.

Production deployment checklist

Frequently Asked Questions

When should I return 403 instead of 401?

Return 401 when a different or refreshed credential could plausibly change the outcome, such as a missing, expired or invalid token. Return 403 when the identity is already established and simply lacks the required permission, because retrying with the same credential cannot help and a challenge header would be meaningless.

Is the WWW-Authenticate header really mandatory?

Yes. HTTP defines 401 as one half of a challenge-response exchange, so a 401 without a challenge is a protocol violation. Practically, many client libraries key their token-refresh logic on the presence of that header and will surface a hard error instead of retrying when it is absent.

Why must the 401 body be generic?

Because a specific body is an oracle. If it distinguishes an expired token from a bad signature from an unknown key id, an attacker probing forged credentials learns which of their guesses were structurally correct. Emit one constant body for every failure and keep the specific reason in your structured logs.

What happens if I forget cache-control no-store?

A shared cache tier can store the rejection under a key that does not include the credential and then replay it to a properly authenticated request for the same URL. The result is users being intermittently signed out for the lifetime of the cached entry. Set no-store and private, and add Vary on Authorization and Cookie as a second line of defence.

How do I avoid redirecting API calls to a login page?

Branch on Sec-Fetch-Mode, which is set to navigate only on top-level document navigations and never on a programmatic fetch. Fall back to the Accept header for clients that do not send it, and default anything unrecognised to the API branch so a misclassified fetch never receives HTML under a 200.

Should the login redirect use 302 or 303?

Use 303. A rejected POST retried as a POST against the login page is meaningless, and 303 is the status that requires the client to change the method to GET. A 302 historically permitted method preservation and some clients still do preserve it.