nextjs-lite-passwall
Next.js Password Wall
Add a single-password auth wall to any Next.js project. One password, one cookie, no database. Designed for protecting personal tools, dashboards, and internal apps you want accessible from anywhere but not public.
When to Use
- Deploying a personal/internal Next.js app that needs basic protection
- "Add a password to this" or "lock this down before deploying"
- Need deployment protection without a full auth system
When NOT to Use
- Multi-user auth with accounts, roles, permissions
- OAuth / social login
- Apps that already have an auth system
Architecture
Request → proxy.ts → JWT cookie valid? → App
→ No cookie/invalid → /login (UI routes)
→ No cookie/invalid → 401 JSON (API routes)
→ No secret in prod → 503/redirect (safe lockdown)
Three files, one dependency (bcryptjs), two env vars for production.
Implementation
Step 1: Install dependency
npm install bcryptjs jose
npm install -D @types/bcryptjs
bcryptjs— password hashing (pure JS, no native deps)jose— JWT signing/verification (edge-compatible, works in middleware)
Step 2: Create lib/auth.ts
Session management utilities. Handles JWT creation, verification, and cookie config.
import { SignJWT, jwtVerify } from "jose";
import { cookies } from "next/headers";
const COOKIE_NAME = "session";
function getSecret() {
const secret = process.env.SESSION_SECRET;
if (!secret) throw new Error("SESSION_SECRET is not set");
return new TextEncoder().encode(secret);
}
export async function createSessionToken(): Promise<string> {
return new SignJWT({ authenticated: true })
.setProtectedHeader({ alg: "HS256" })
.setIssuedAt()
.setExpirationTime("30d")
.sign(getSecret());
}
export async function verifySessionToken(token: string): Promise<boolean> {
try {
await jwtVerify(token, getSecret());
return true;
} catch {
return false;
}
}
export function getSessionCookieConfig(token: string) {
return {
name: COOKIE_NAME,
value: token,
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax" as const,
maxAge: 30 * 24 * 60 * 60, // 30 days
path: "/",
};
}
export async function getSessionFromCookies(): Promise<boolean> {
const cookieStore = await cookies();
const token = cookieStore.get(COOKIE_NAME)?.value;
if (!token) return false;
return verifySessionToken(token);
}
export { COOKIE_NAME };
Customization notes:
- Rename
COOKIE_NAMEto match your app (e.g."myapp_session") - Adjust
setExpirationTime("30d")andmaxAgeif you want shorter sessions
Step 3: Create proxy.ts (or middleware.ts for Next.js < 16)
The proxy intercepts every request and checks for a valid session.
Next.js 16+ uses
proxy.ts(exportedproxyfunction). Next.js 15 and earlier usesmiddleware.ts(exportedmiddlewarefunction). The logic is identical — only the file name and export name differ.
import { NextRequest, NextResponse } from "next/server";
import { jwtVerify } from "jose";
const COOKIE_NAME = "session"; // Must match lib/auth.ts
const PUBLIC_PATHS = ["/login", "/api/auth"];
const STATIC_PREFIXES = ["/_next", "/favicon"];
function isPublicPath(pathname: string): boolean {
return (
PUBLIC_PATHS.some((p) => pathname === p || pathname.startsWith(p + "/")) ||
STATIC_PREFIXES.some((p) => pathname.startsWith(p))
);
}
export async function proxy(request: NextRequest) {
const { pathname } = request.nextUrl;
if (isPublicPath(pathname)) {
return NextResponse.next();
}
const secret = process.env.SESSION_SECRET;
if (!secret) {
// No secret configured
if (process.env.NODE_ENV === "production") {
// Production without secret = locked down (safe default)
if (pathname.startsWith("/api/")) {
return NextResponse.json({ error: "Auth not configured" }, { status: 503 });
}
const loginUrl = new URL("/login", request.url);
loginUrl.searchParams.set("error", "not-configured");
return NextResponse.redirect(loginUrl);
}
// Development without secret = auth disabled (convenience)
return NextResponse.next();
}
const token = request.cookies.get(COOKIE_NAME)?.value;
if (token) {
try {
await jwtVerify(token, new TextEncoder().encode(secret));
return NextResponse.next();
} catch {
// Invalid/expired token — fall through
}
}
if (pathname.startsWith("/api/")) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const loginUrl = new URL("/login", request.url);
loginUrl.searchParams.set("from", pathname);
return NextResponse.redirect(loginUrl);
}
export const config = {
matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
};
For Next.js 15 and earlier: rename to middleware.ts and change export async function proxy to export async function middleware.
Step 4: Create login API route at app/api/auth/login/route.ts
import { NextRequest, NextResponse } from "next/server";
import bcrypt from "bcryptjs";
import { createSessionToken, getSessionCookieConfig } from "@/lib/auth";
// In-memory rate limiting
const attempts = new Map<string, { count: number; resetAt: number }>();
const MAX_ATTEMPTS = 5;
const WINDOW_MS = 60_000;
function isRateLimited(ip: string): boolean {
const now = Date.now();
const record = attempts.get(ip);
if (!record || now > record.resetAt) {
attempts.set(ip, { count: 1, resetAt: now + WINDOW_MS });
return false;
}
record.count++;
return record.count > MAX_ATTEMPTS;
}
export async function POST(request: NextRequest) {
const ip = request.headers.get("x-forwarded-for") || "unknown";
if (isRateLimited(ip)) {
return NextResponse.json(
{ error: "Too many attempts. Try again in a minute." },
{ status: 429 }
);
}
const hash = process.env.PASSWORD_HASH;
if (!hash) {
return NextResponse.json({ error: "Auth not configured" }, { status: 500 });
}
let body: { password?: string };
try {
body = await request.json();
} catch {
return NextResponse.json({ error: "Invalid request" }, { status: 400 });
}
const { password } = body;
if (!password || typeof password !== "string") {
return NextResponse.json({ error: "Password required" }, { status: 400 });
}
const valid = await bcrypt.compare(password, hash);
if (!valid) {
return NextResponse.json({ error: "Wrong password" }, { status: 401 });
}
const token = await createSessionToken();
const response = NextResponse.json({ ok: true });
response.cookies.set(getSessionCookieConfig(token));
return response;
}
Step 5: Create login page at app/login/page.tsx
Minimal password form. Adapt styling to match the project's design system.
"use client";
import { Suspense, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
export default function LoginPage() {
return (
<Suspense>
<LoginForm />
</Suspense>
);
}
function LoginForm() {
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const router = useRouter();
const searchParams = useSearchParams();
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setError("");
setLoading(true);
try {
const res = await fetch("/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ password }),
});
if (res.ok) {
const from = searchParams.get("from") || "/";
router.push(from);
router.refresh();
} else {
const data = await res.json();
setError(data.error || "Login failed");
}
} catch {
setError("Something went wrong");
} finally {
setLoading(false);
}
}
return (
<div style={{ display: "flex", minHeight: "100vh", alignItems: "center", justifyContent: "center" }}>
<div style={{ width: "100%", maxWidth: 320 }}>
<form onSubmit={handleSubmit}>
<input
type="password"
placeholder="Password"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoFocus
autoComplete="current-password"
style={{ width: "100%", padding: 8, marginBottom: 8 }}
/>
{error && <p style={{ color: "red", fontSize: 14 }}>{error}</p>}
<button type="submit" disabled={!password || loading} style={{ width: "100%", padding: 8 }}>
{loading ? "Logging in..." : "Log in"}
</button>
</form>
</div>
</div>
);
}
Styling: This uses inline styles as a baseline. Replace with the project's component library (shadcn/ui Button + Input, Tailwind classes, etc.) to match the existing design.
Step 6: Create password hash script at scripts/generate-password-hash.ts
#!/usr/bin/env npx tsx
import bcrypt from "bcryptjs";
const password = process.argv[2];
if (!password) {
console.error("Usage: npx tsx scripts/generate-password-hash.ts <password>");
process.exit(1);
}
const hash = bcrypt.hashSync(password, 12);
console.log(hash);
Step 7: Add security headers (optional but recommended)
Add to next.config.ts (or .js/.mjs):
const securityHeaders = [
{ key: "X-Frame-Options", value: "DENY" },
{ key: "X-Content-Type-Options", value: "nosniff" },
{ key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
{ key: "Strict-Transport-Security", value: "max-age=63072000; includeSubDomains; preload" },
];
// Inside your Next.js config:
// headers: async () => [{ source: "/(.*)", headers: securityHeaders }]
Environment Variables
| Variable | Required | Where | Description |
|---|---|---|---|
PASSWORD_HASH |
Production | Hosting env | bcrypt hash from scripts/generate-password-hash.ts |
SESSION_SECRET |
Production | Hosting env | Random string: openssl rand -hex 32 |
Local dev: Neither variable is needed. Without SESSION_SECRET, the proxy passes all requests through — no login required during development.
Production without vars: The app locks down safely. API returns 503, UI redirects to login with an error. Nothing is exposed.
Setup Commands (for deployment)
# Generate password hash
npx tsx scripts/generate-password-hash.ts your-password-here
# → Copy the output hash to PASSWORD_HASH env var
# Generate session secret
openssl rand -hex 32
# → Copy to SESSION_SECRET env var
Customization Checklist
After implementing, adjust these to fit the project:
-
COOKIE_NAMEinlib/auth.tsandproxy.ts— use an app-specific name -
PASSWORD_HASH/SESSION_SECRETenv var names — prefix with app name if preferred -
PUBLIC_PATHSinproxy.ts— add any routes that should be accessible without auth (webhooks, health checks, public APIs) - Login page styling — match the project's design system
- Session duration — default is 30 days, adjust in both
lib/auth.ts(JWT expiry + cookie maxAge) - Rate limit thresholds — default is 5 attempts per minute per IP
More from benbennitt/skills
self-enhance
Review recent work, identify process gaps and repeated mistakes, and produce specific file edits to prevent them. Not a reflection exercise — outputs config and identity changes. Trigger manually after sprints, or automate weekly.
9commit
Write clean, consistent git commit messages with sentence case summaries and agent attribution. Use when committing code changes.
1