Skip to content
You are reading the frozen Warlock.js v4 documentation. Read the current v5 docs | Upgrade to v5
This page documents Warlock v4. View the latest docs.

Your first protected route

You’ve installed the package, generated secrets, and migrated the token tables. Now ship the smallest end-to-end flow: register, log in, hit a protected route.

This walks through three controllers and one model — under 60 lines of code.

src/app/users/models/user.model.ts
import { Auth } from "@warlock.js/auth";
import { RegisterModel } from "@warlock.js/cascade";
import { v } from "@warlock.js/seal";
const userSchema = v.object({
email: v.string().email().required(),
name: v.string().min(2).max(120).required(),
password: v.string().required(),
});
@RegisterModel()
export class User extends Auth<typeof userSchema> {
public static table = "users";
public static schema = userSchema;
public static toJsonColumns = ["id", "email", "name", "created_at"];
public get userType() {
return "user";
}
}

Three things to notice:

  • extends Auth<...> — your model inherits createTokenPair, confirmPassword, activeSessions, etc. from the auth base.
  • public get userType() — the slug. It must match a key in src/config/auth.tsuserType.
  • toJsonColumns — the explicit allowlist for the JSON response. Without this, the password hash leaks.

Register the model class in your auth config:

src/config/auth.ts
import type { AuthConfigurations } from "@warlock.js/auth";
import { env } from "@warlock.js/core";
import { User } from "@/app/users/models/user.model";
const authConfig: AuthConfigurations = {
userType: { user: User },
jwt: {
secret: env("JWT_SECRET"),
expiresIn: "1h",
refresh: { secret: env("JWT_REFRESH_SECRET"), enabled: true, expiresIn: "30d" },
},
};
export default authConfig;

Also write the matching Cascade migration for the users table — see Cascade migrations for the shape.

src/app/users/controllers/register.controller.ts
import { authService } from "@warlock.js/auth";
import { hashPassword, type Request, type Response } from "@warlock.js/core";
import { User } from "../models/user.model";
export async function registerController(request: Request, response: Response) {
const { email, name, password } = request.all();
const existing = await User.first({ email });
if (existing) {
return response.badRequest({ error: "Email already registered" });
}
const user = await User.create({
email,
name,
password: await hashPassword(password),
});
const tokens = await authService.createTokenPair(user, {
userAgent: request.header("user-agent"),
ip: request.ip,
});
return response.successCreate({ user, tokens });
}

That’s the full register flow:

  1. Duplicate check.
  2. Create user with hashed password.
  3. Issue access + refresh pair.
  4. Respond.
src/app/users/controllers/login.controller.ts
import { authService } from "@warlock.js/auth";
import type { Request, Response } from "@warlock.js/core";
import { User } from "../models/user.model";
export async function loginController(request: Request, response: Response) {
const result = await authService.login(
User,
{
email: request.input("email"),
password: request.input("password"),
},
{
userAgent: request.header("user-agent"),
ip: request.ip,
},
);
if (!result) {
return response.unauthorized({ error: "Invalid credentials" });
}
return response.success(result);
}

authService.login is the full happy path — verifies password, creates the token pair, fires events. Returns null on a miss; you map that to a 401.

src/app/users/controllers/me.controller.ts
import type { Request, Response } from "@warlock.js/core";
export async function meController(request: Request, response: Response) {
return response.success({ user: request.user });
}

request.user is hydrated by the middleware before this controller runs. No manual decoding, no extra DB roundtrip in your code.

src/app/users/routes.ts
import { authMiddleware } from "@warlock.js/auth";
import { router } from "@warlock.js/core";
import { loginController } from "./controllers/login.controller";
import { meController } from "./controllers/me.controller";
import { registerController } from "./controllers/register.controller";
router.post("/register", registerController);
router.post("/login", loginController);
router.get("/me", meController, { middleware: [authMiddleware("user")] });

Only /me gets authMiddleware("user") — passed via the route’s middleware array. Register and login must be reachable without a token, so they get no middleware.

Terminal window
# Register
curl -X POST http://localhost:3000/register \
-H "Content-Type: application/json" \
-d '{"email":"ada@example.com","name":"Ada","password":"correct-horse-battery-staple"}'
# Response: { user: {...}, tokens: { accessToken: { token, expiresAt }, refreshToken: { token, expiresAt } } }
# Hit /me with the access token
curl http://localhost:3000/me \
-H "Authorization: Bearer <accessToken.token>"
# Response: { user: { id, email, name, created_at } }
# Hit /me without a token
curl http://localhost:3000/me
# Response: 401 { error: "...", errorCode: "EC001" }

Done. You have register, login, and a protected endpoint. The rest of the documentation digs into the pieces.

  • The auth flow — the full lifecycle including refresh and logout.
  • Protect routes — middleware modes, route groups, user-type combos.
  • Manage tokens — rotation, family revocation, active sessions.