How I build my Middleware to Secure Sensitive Transmitted Data (Next.js:TS Case Study)

Safe Type Declaration

1import { ApiKey, User } from "@prisma/client";
2
3export type AuthContext = {
4  type: AuthType;
5  user?: Partial<User>;
6  apiKey?: Partial<ApiKey>;
7};
8
9export enum AuthType {
10  USER_SESSION = "USER_SESSION",
11  API_KEY = "API_KEY",
12  MIXED_AUTH = "MIXED_AUTH",
13  PUBLIC = "PUBLIC"
14}

Notice how I used Prisma’s generated types to define a clear, concise authentication context for every request. By importing ApiKey and User from @prisma/client, I can reference the exact shape of those database models. The AuthContext type then combines an explicit AuthType enum distinguishing between user sessions, API keys, mixed authentication, or public access with optional, partial views of User and ApiKey. This ensures downstream code only ever sees the fields I explicitly expose (thanks to Partial<T>), preventing accidental leaks of sensitive data while still providing strong type safety and IntelliSense throughout the request-handling pipeline.

Securing Your Edge with Origin Validation and CORS

Every incoming request first runs through your edge middleware, so you want to block any untrusted origins before doing anything else. We pull the Origin header, compare it against a small allow-list (your production URL, localhost, and VS Code webviews), and immediately answer any OPTIONS preflight with the correct CORS headers. That way, unauthenticated or non-browser clients can’t sneak in, and genuine browsers get the credentials they need to send cookies.

1const ALLOWED_ORIGINS = [
2  process.env.NEXT_PUBLIC_PROD_URL,
3  "http://localhost:3000",
4  "vscode-webview://*"
5];
6
7export async function middleware(req: NextRequest) {
8  const origin = req.headers.get("origin") || "";
9  const isAllowedOrigin = ALLOWED_ORIGINS.some(allowed =>
10    allowed && origin.startsWith(allowed.replace('*', ''))
11  );
12
13  if (req.method === "OPTIONS") {
14    return handleCORS(req, isAllowedOrigin ? origin : "");
15  }
16
17  // …continue processing…
18}
19
20function handleCORS(req: NextRequest, origin: string) {
21  const headers = new Headers();
22  if (origin) {
23    headers.set("Access-Control-Allow-Origin", origin);
24    headers.set("Access-Control-Allow-Credentials", "true");
25  }
26  headers.set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS");
27  headers.set("Access-Control-Allow-Headers", "Content-Type, Authorization");
28  headers.set("Access-Control-Max-Age", "600");
29  return new NextResponse(null, { headers });
30}

Enforcing Session Freshness with JWT Claims

After origin checks, we load the user’s session token via NextAuth’s getToken(). Immediately we validate the iat (issued-at) claim to catch any malformed or legacy tokens, then compute how long ago that token was issued. If it’s older than four hours (or a one-minute window in test mode), we redirect the user to sign back in deleting both their session and API-key cookies in one atomic action.

1import { getToken, JWT } from "next-auth/jwt";
2
3export async function middleware(req: NextRequest) {
4  // …origin/CORS logic…
5
6  const token = await getToken({ req });
7  if (token) {
8    if (!token.iat) {
9      console.error("Token missing iat claim");
10      return forceLogout(req);
11    }
12    const ageMs = Date.now() - Number(token.iat) * 1000;
13    const MAX_AGE = 4 * 60 * 60 * 1000;   // 4 hours
14    if (ageMs > MAX_AGE) {
15      return forceLogout(req);
16    }
17  }
18
19  // …continue processing…
20}
21
22function forceLogout(req: NextRequest) {
23  const res = NextResponse.redirect(new URL('/auth/signin', req.url));
24  res.cookies.delete('next-auth.session-token');
25  res.cookies.delete('api-key');
26  return res;
27}

Offloading DB-Heavy Auth to a Secure Internal API

Edge middleware can’t run Prisma queries directly, so we “offload” validation to an internal API route that lives in a Node.js environment. For any path like /api/assistant, we collect the raw API-key cookie and session email, then POST to /api/internal/auth protected by a secret header. The internal route uses Prisma to verify credentials and returns a full authContext. We strip out sensitive bits, serialize it, and attach it as an x-auth-context header for downstream handlers.

1export async function middleware(req: NextRequest) {
2  // …session validation…
3
4  if (req.nextUrl.pathname.startsWith("/api/assistant")) {
5    const { error, authContext, response } = await handleAPIAuth(req, token);
6    if (error) return response;
7
8    const safe = {
9      user: authContext.user && { id: authContext.user.id, email: authContext.user.email },
10      apiKey: authContext.apiKey && {
11        id: authContext.apiKey.id,
12        userId: authContext.apiKey.userId,
13        scopes: authContext.apiKey.scopes,
14        type: authContext.apiKey.type,
15        expiresAt: authContext.apiKey.expiresAt,
16      }
17    };
18
19    const headers = new Headers(req.headers);
20    headers.set('x-auth-context', JSON.stringify(safe));
21    return NextResponse.next({ request: { headers } });
22  }
23
24  // …continue processing…
25}
26
27async function handleAPIAuth(req: NextRequest, token: JWT | null) {
28  const rawKey = req.cookies.get('api-key')?.value || null;
29  try {
30    const apiUrl = new URL('/api/internal/auth', req.url).toString();
31    const res = await fetch(apiUrl, {
32      method: 'POST',
33      headers: {
34        'Content-Type': 'application/json',
35        'x-internal-secret': process.env.INTERNAL_SECRET!
36      },
37      body: JSON.stringify({ apiKey: rawKey, sessionEmail: token?.email || null })
38    });
39    const data = await res.json();
40    if (!res.ok) throw new Error(data.error);
41    return { error: false, authContext: data.authContext };
42  } catch (err) {
43    if (err instanceof RateLimitError) {
44      return { error: true, response: new NextResponse(err.message, { status: 429 }) };
45    }
46    if (err instanceof AuthorizationError) {
47      return { error: true, response: new NextResponse(err.message, { status: 403 }) };
48    }
49    return { error: true, response: new NextResponse("Authentication failed", { status: 401 }) };
50  }
51}

Hardening Every Response with Security Headers

Once authentication and special routes are handled, we layer on a suite of HTTP security headers CSP, HSTS, X-Frame-Options, and more pulled from a single SECURITY_HEADERS constant. This DRY approach assures consistent protection across your entire site or API surface.

1import { SECURITY_HEADERS } from "./lib/securityHeaders";
2
3export async function middleware(req: NextRequest) {
4  // …all previous logic…
5
6  const res = NextResponse.next();
7  Object.entries(SECURITY_HEADERS).forEach(([k, v]) => {
8    res.headers.set(k, v);
9  });
10  return res;
11}

Enabling Rate Limiting with Upstash

We will throttle clients to 100 requests per minute using Upstash Redis. Analytics are built-in, and a burst of traffic will receive HTTP 429 errors automatically.

1import { Ratelimit } from "@upstash/ratelimit";
2import { Redis } from "@upstash/redis";
3
4const ratelimit = new Ratelimit({
5  redis: Redis.fromEnv(),
6  limiter: Ratelimit.slidingWindow(100, "1 m"),
7  analytics: true
8});
9
10export async function middleware(req: NextRequest) {
11  // …before response…
12  await ratelimit.limit(req.ip);
13  // …rest of logic…
14}

This pattern succeeds by keeping edge middleware lean only handling header checks, token parsing, and minimal validation while offloading all Prisma and other database interactions to a secured internal API guarded by your INTERNAL_SECRET. By centralizing CORS policies and security headers in one place, every route benefits from uniform protection without duplication. Detailed logging of token issuance, session age, and internal API responses further enhances observability, helping you detect clock skew or serialization issues quickly. Embedding this approach in your Next.js application delivers middleware that’s both lightning-fast at the edge and robustly secure, yet remains easy to extend as your needs evolve.