Adding a JWT Login Guard to a Next.js App Router Demo — Without NextAuth

Apr 15, 2026 6 min read

Public AI demos that hit your own OpenAI key need a gate, not a full auth system. This article shows how to protect a Next.js App Router app with a shared access code, a signed JWT cookie, and middleware—no NextAuth, no database, just jose and four small files.

How to protect a Next.js App Router application with a shared password, a signed JWT cookie, and middleware — using only the jose library and no additional auth framework.

ShopAgent is a public demo that uses an OpenAI API key. Without any access control, anyone who finds the URL can run as many queries as they want on our account. We needed a simple gate: enter a shared access code, get a 45-minute session, and any request without a valid session gets redirected to the login page.

NextAuth (Auth.js) is the default choice for Next.js authentication. But NextAuth is built for user accounts, OAuth providers, and persistent sessions. We needed none of that. We needed a single shared password and a short-lived cookie. NextAuth was the wrong tool.

This post walks through the lightweight implementation we used: Pydantic model for the session, jose for JWT signing and verification, a middleware for route protection, and two Route Handlers for login and logout.

WHY NOT NEXTAUTH

 

NextAuth requires a database (or an adapter) for session storage, an array of providers configured in the route handler, and callbacks to control what gets stored in the session. For user-level authentication this is the right trade-off. For a single shared password, it is significant setup for minimal value.

The goal here was simpler: one password checked against an environment variable, one cookie set on success, and a middleware that redirects unauthenticated requests to the login page. Four files. No database. No provider configuration.

THE JWT SESSION MODEL

 

The session is a signed JWT stored in an httpOnly cookie. The JWT payload is minimal:

// The session carries just enough to verify it's legitimate
{ demo: true }

No user ID, no role, no email. The presence of a valid, unexpired JWT is sufficient to allow access. If the signature is valid and the token has not expired, the request goes through.

The secret key is a 32-character minimum string set via the DEMO_SESSION_SECRET environment variable:

function getSecret(): Uint8Array {
    const secret = process.env.DEMO_SESSION_SECRET;
    if (!secret || secret.length < 32) {
        throw new Error(
            "DEMO_SESSION_SECRET env var is missing or too short (min 32 chars). "
            + "Generate one with: openssl rand -hex 32"
        );
    }
    return new TextEncoder().encode(secret);
}

The secret is encoded to Uint8Array because jose's HMAC key functions expect bytes, not strings.

THE SESSION.TS HELPERS

 

Two functions do all the cryptographic work:

// frontend/src/lib/session.ts
import { SignJWT, jwtVerify, type JWTPayload } from "jose";

const SESSION_DURATION_SECONDS = 45 * 60; // 45 minutes

export async function encrypt(payload: JWTPayload): Promise<string> {
    return new SignJWT(payload)
        .setProtectedHeader({ alg: "HS256" })
        .setIssuedAt()
        .setExpirationTime(`${SESSION_DURATION_SECONDS}s`)
        .sign(getSecret());
}

export async function decrypt(token: string | undefined): Promise<JWTPayload | null> {
    if (!token) return null;
    try {
        const { payload } = await jwtVerify(token, getSecret());
        return payload;
    } catch {
        return null;
    }
}

encrypt creates a signed JWT with an HS256 signature and a 45-minute expiry. decrypt verifies the token and returns the payload, or null if the token is missing, expired, or tampered with. The catch block returning null means all error cases are treated the same: unauthenticated.

This module has a deliberate note in the source: do not add import "server-only". The module is imported by both the login Route Handler (which runs in the Node.js runtime) and the middleware (which runs in the Edge runtime). The "server-only" constraint would prevent the middleware import.

THE LOGIN ROUTE HANDLER

 

// frontend/src/app/api/auth/login/route.ts
export async function POST(request: NextRequest) {
    let password: string;
    try {
        const body = await request.json();
        password = String(body?.password ?? "");
    } catch {
        return NextResponse.json({ error: "Invalid request" }, { status: 400 });
    }

    const expected = process.env.DEMO_ACCESS_PASSWORD;
    if (!expected) {
        return NextResponse.json({ error: "Server misconfiguration" }, { status: 500 });
    }

    // Timing-safe comparison prevents timing attacks
    const passwordBytes = Buffer.from(password);
    const expectedBytes = Buffer.from(expected);
    const match =
        passwordBytes.length === expectedBytes.length &&
        require("crypto").timingSafeEqual(passwordBytes, expectedBytes);

    if (!match) {
        return NextResponse.json({ error: "Incorrect access code" }, { status: 401 });
    }

    const token = await encrypt({ demo: true });
    const response = NextResponse.json({ ok: true });
    const secureCookie = process.env.DEMO_COOKIE_SECURE === "true";

    response.cookies.set(SESSION_COOKIE, token, {
        httpOnly: true,
        secure: secureCookie,
        sameSite: "lax",
        path: "/",
        maxAge: SESSION_DURATION_SECONDS,
    });

    return response;
}

timingSafeEqual prevents timing attacks on the password comparison. A naive password === expected check leaks information through response time — shorter mismatches return faster because the comparison stops at the first differing character. timingSafeEqual takes constant time regardless of where the strings differ.

secure: secureCookie is conditional. Local development uses HTTP, so secure: true would prevent the cookie from being set. On the production server, DEMO_COOKIE_SECURE=true is set in the Docker Compose override file, enabling the secure flag for HTTPS-only delivery.

THE LOGOUT ROUTE HANDLER

 

// frontend/src/app/api/auth/logout/route.ts
export function GET(request: NextRequest) {
    const host = request.headers.get("x-forwarded-host") ?? request.nextUrl.host;
    const proto = request.headers.get("x-forwarded-proto") ?? request.nextUrl.protocol.replace(":", "");
    const loginUrl = `${proto}://${host}/login`;

    const response = NextResponse.redirect(loginUrl);
    const secureCookie = process.env.DEMO_COOKIE_SECURE === "true";

    response.cookies.set(SESSION_COOKIE, "", {
        httpOnly: true,
        secure: secureCookie,
        sameSite: "lax",
        path: "/",
        maxAge: 0,
    });

    return response;
}

The maxAge: 0 immediately expires the cookie. The redirect uses x-forwarded-host and x-forwarded-proto headers because behind Traefik, the internal request URL is http://0.0.0.0:3000 — not the public domain. Using the forwarded headers reconstructs the correct public URL for the redirect.

THE MIDDLEWARE

 

// frontend/src/middleware.ts
import { NextRequest, NextResponse } from "next/server";
import { decrypt, SESSION_COOKIE } from "@/lib/session";

function isPublic(pathname: string): boolean {
    return (
        pathname.startsWith("/login") ||
        pathname.startsWith("/api/auth/") ||
        pathname.startsWith("/api/copilotkit") || // CopilotKit server-to-server calls
        pathname.startsWith("/_next/") ||
        pathname.startsWith("/favicon")
    );
}

export default async function middleware(request: NextRequest) {
    const { pathname } = request.nextUrl;

    if (isPublic(pathname)) {
        // If authenticated and hitting /login → redirect to app
        if (pathname === "/login") {
            const token = request.cookies.get(SESSION_COOKIE)?.value;
            const session = await decrypt(token);
            if (session) {
                return NextResponse.redirect(new URL("/", request.nextUrl));
            }
        }
        return NextResponse.next();
    }

    const token = request.cookies.get(SESSION_COOKIE)?.value;
    const session = await decrypt(token);

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

    return NextResponse.next();
}

export const config = {
    matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
};

The /api/copilotkit path is in the public list because CopilotKit makes server-to-server calls to this endpoint during the AG-UI streaming session. The browser auth cookie is not forwarded in those internal calls. Since the orchestrator is not publicly accessible anyway, this exclusion is safe.

THE EDGE RUNTIME CONSTRAINT

 

Next.js middleware runs in the Edge runtime, not Node.js. The Edge runtime does not have fs, crypto (Node.js's built-in), or any Node.js-specific APIs.

jose was chosen specifically because it works in the Edge runtime. It uses the Web Crypto API (SubtleCrypto) which is available everywhere — browsers, Edge runtime, Node.js, Deno.

The login Route Handler does use require("crypto").timingSafeEqual because Route Handlers run in Node.js, not the Edge runtime. This is safe. But the same require("crypto") call in middleware would fail.

For timing-safe comparison in the Edge runtime, use:

 

// Edge-compatible timing-safe comparison
import { timingSafeEqual } from "crypto"; // Not available in Edge
// Use instead:
const encoder = new TextEncoder();
const a = encoder.encode(password);
const b = encoder.encode(expected);
// Then compare with SubtleCrypto

Since password comparison happens in the Route Handler (Node.js), not in middleware (Edge), the Node.js crypto.timingSafeEqual works fine in our case.

WHAT WE'D DO DIFFERENTLY

 

Rate limiting — The current login endpoint has no rate limiting. An automated script could try thousands of passwords. Adding rate limiting on the login route (by IP, with a short lock-out after N failures) would mitigate brute-force attacks.

Session refresh — Sessions expire after 45 minutes with no renewal. A user actively using the demo when the session expires is abruptly redirected to the login page. Refreshing the session cookie on each authenticated request — extending the expiry by 45 minutes from the last activity — would fix this.

THE TAKEAWAY

 

For simple demo access control, the full NextAuth stack is overkill. Four files (session.ts, middleware.ts, login/route.ts, logout/route.ts) and one npm package (jose) give you a complete, secure JWT session system: timing-safe password comparison, signed and expiring cookies, route protection at the middleware level, and automatic redirect to the login page for unauthenticated requests.

The key constraints to get right: jose for Edge-compatible JWT operations, timingSafeEqual for the password check, and x-forwarded-host for correct redirects behind a proxy.

 

The ShopAgent demo is live at https://shop-agent.agilecreativeminds.nl. See the demo showcase or follow the demo walkthrough. Built by Agile Creative Minds.