Building a Full-Stack Feature with IronCode: Auth in 8 Minutes
A real walkthrough of using IronCode to build JWT authentication from scratch — route handlers, middleware, tests, and all.
This is a real walkthrough, not a demo with a curated prompt and a perfect result on the first try. We're going to build JWT authentication for a Next.js API — route handlers, middleware, a session helper, and tests — using IronCode on a fresh project.
Total time, including reading this post: about 8 minutes of actual work.
The starting point
A plain Next.js 14 app with App Router. No auth, no database yet. We'll add:
POST /api/auth/login— validates credentials, returns a JWTPOST /api/auth/logout— clears the session cookieGET /api/auth/me— returns the current user (protected)- Middleware that protects all
/api/protected/*routes - Tests for the happy path and common failure cases
Step 1: Give IronCode the spec
$ ironcode
> Build JWT authentication for this Next.js app. I need:
> - POST /api/auth/login that validates email+password and returns a JWT in an httpOnly cookie
> - POST /api/auth/logout that clears the cookie
> - GET /api/auth/me that returns the current user from the JWT
> - Middleware protecting /api/protected/* routes
> - Tests for all of the above
> Use jose for JWT, store users in a hardcoded array for now (we'll replace later)
IronCode starts reading: package.json (to check what's installed), app/ (to understand the router structure), tsconfig.json (for path aliases).
It installs jose via bun add jose, then starts writing.
What it produces
lib/auth.ts — the JWT utilities
import { SignJWT, jwtVerify } from "jose";
const JWT_SECRET = new TextEncoder().encode(
process.env.JWT_SECRET ?? "dev-secret-change-in-production"
);
export interface JWTPayload {
sub: string;
email: string;
iat: number;
exp: number;
}
export async function signToken(payload: Omit<JWTPayload, "iat" | "exp">) {
return new SignJWT(payload)
.setProtectedHeader({ alg: "HS256" })
.setIssuedAt()
.setExpirationTime("24h")
.sign(JWT_SECRET);
}
export async function verifyToken(token: string): Promise<JWTPayload | null> {
try {
const { payload } = await jwtVerify(token, JWT_SECRET);
return payload as JWTPayload;
} catch {
return null;
}
}
app/api/auth/login/route.ts
import { NextResponse } from "next/server";
import { signToken } from "@/lib/auth";
// Hardcoded for now — replace with DB lookup
const USERS = [
{ id: "1", email: "[email protected]", password: "password123" },
];
export async function POST(req: Request) {
const { email, password } = await req.json();
const user = USERS.find(
(u) => u.email === email && u.password === password
);
if (!user) {
return NextResponse.json({ error: "Invalid credentials" }, { status: 401 });
}
const token = await signToken({ sub: user.id, email: user.email });
const response = NextResponse.json({ ok: true });
response.cookies.set("token", token, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
maxAge: 60 * 60 * 24, // 24h
});
return response;
}
middleware.ts
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
import { verifyToken } from "@/lib/auth";
export async function middleware(req: NextRequest) {
if (!req.nextUrl.pathname.startsWith("/api/protected")) {
return NextResponse.next();
}
const token = req.cookies.get("token")?.value;
if (!token) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const payload = await verifyToken(token);
if (!payload) {
return NextResponse.json({ error: "Invalid token" }, { status: 401 });
}
return NextResponse.next();
}
export const config = {
matcher: ["/api/protected/:path*"],
};
Step 2: The tests
IronCode writes tests before asking if they pass. Running bun test:
✓ POST /api/auth/login > returns 200 with valid credentials
✓ POST /api/auth/login > returns 401 with wrong password
✓ POST /api/auth/login > returns 401 with unknown email
✓ POST /api/auth/logout > clears the cookie
✓ GET /api/auth/me > returns user when token is valid
✓ GET /api/auth/me > returns 401 when no token
✓ middleware > allows requests to unprotected routes
✓ middleware > blocks requests to /api/protected without token
✓ middleware > blocks requests with expired token
9 tests passed in 312ms
All green on the first run.
What IronCode got right without being told
A few things worth noting that weren't in the prompt:
httpOnly: trueon the cookie — it didn't need to be asked to use the secure defaultsecure: process.env.NODE_ENV === "production"— environment-aware without prompting- The
verifyTokenreturnsnullinstead of throwing — cleaner error handling pattern - The middleware checks
startsWithfirst before touching the token — avoids unnecessary JWT work on public routes
These aren't magical. They're patterns IronCode sees in well-written codebases and reproduces. But they're the kind of details that distinguish good code from code that works.
The next step
This is a solid foundation. To make it production-ready you'd replace the hardcoded user array with a database lookup — which you can do in one more IronCode prompt:
> Replace the hardcoded USERS array in the login route with a Prisma query.
> Use the User model that's already in schema.prisma.
That's a three-minute task. IronCode reads your schema, writes the query, handles the null case, and updates the test to mock prisma.user.findUnique.