>_IronCode
Tutorial

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.

January 5, 202610 min read

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 JWT
  • POST /api/auth/logout — clears the session cookie
  • GET /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: true on the cookie — it didn't need to be asked to use the secure default
  • secure: process.env.NODE_ENV === "production" — environment-aware without prompting
  • The verifyToken returns null instead of throwing — cleaner error handling pattern
  • The middleware checks startsWith first 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.

Ready to try IronCode?

Install IronCode and start coding smarter today.