Auth
Published Oct 3, 2025 | Updated Mar 13, 2026
Add production-ready authentication with Better Auth or JWT
Overview
Add production-ready email/password authentication to your backend.
zuro add auth now supports two providers:
better-auth(default): session/cookie auth via Better Auth.jwt: access/refresh token auth with local JWT handlers.
Install
Interactive choice:
npx zuro add authDirect provider selection:
npx zuro add auth --auth-provider better-auth
npx zuro add auth --auth-provider jwtAuth install auto-resolves required module dependencies (database, error-handler) if missing.
What This Generates
Shared files:
src/
├ lib/
│ └ auth.ts
├ controllers/
│ └ user.controller.ts
├ routes/
│ └ user.routes.ts
└ db/schema/
└ auth.ts # mysql projects receive mysql-compatible auth schemaJWT-only additional files:
src/
├ controllers/
│ └ auth.controller.ts
└ routes/
└ auth.routes.tsRoute wiring updates:
- Better Auth:
app.tsmounts/api/authhandler directly. - JWT: route mounts are injected for
/api/authand/api/users.
Provider Behavior
Better Auth (default)
- Uses Better Auth session flow.
- Session is resolved from cookies.
- Best if you want managed auth primitives and plugin ecosystem.
JWT
- Uses signed access + refresh tokens.
- Access token is read from
Authorization: Bearer <token>. - Refresh tokens are persisted (hashed) in DB and rotated on refresh.
Configuration
Better Auth env:
BETTER_AUTH_SECRET=your-secret-key-at-least-32-characters-long
BETTER_AUTH_URL=http://localhost:3000
DATABASE_URL=postgresql://postgres@localhost:5432/mydbJWT env:
JWT_ACCESS_SECRET=your-jwt-access-secret-at-least-32-characters
JWT_REFRESH_SECRET=your-jwt-refresh-secret-at-least-32-characters
JWT_ACCESS_EXPIRES_IN=15m
JWT_REFRESH_EXPIRES_IN=7d
DATABASE_URL=postgresql://postgres@localhost:5432/mydbAPI Reference
Better Auth endpoints:
POST /api/auth/sign-up/emailPOST /api/auth/sign-in/emailPOST /api/auth/sign-outGET /api/auth/get-sessionGET /api/users/me
JWT endpoints:
POST /api/auth/sign-up/emailPOST /api/auth/sign-in/emailPOST /api/auth/refreshPOST /api/auth/sign-outGET /api/auth/get-sessionGET /api/users/me
Quick Example (JWT)
curl -X POST http://localhost:3000/api/auth/sign-in/email \
-H "Content-Type: application/json" \
-d '{
"email": "ava@example.com",
"password": "password123"
}'Use returned accessToken as bearer token:
GET /api/users/me
Authorization: Bearer <access-token>Run Migrations
npx drizzle-kit generate
npx drizzle-kit migrateProtecting Routes
Use the generated auth middleware to guard your routes.
Better Auth — protect a route with session check:
// src/middleware/require-auth.ts
import { auth } from "../lib/auth";
import type { Request, Response, NextFunction } from "express";
export async function requireAuth(req: Request, res: Response, next: NextFunction) {
const session = await auth.api.getSession({ headers: req.headers as Headers });
if (!session) {
return res.status(401).json({ status: "error", code: "UNAUTHORIZED" });
}
(req as Request & { user: typeof session.user }).user = session.user;
next();
}Apply it to any route:
import { requireAuth } from "../middleware/require-auth";
router.get("/profile", requireAuth, async (req, res) => {
res.json({ user: (req as any).user });
});JWT — protect a route with token verification:
The generated src/lib/auth.ts exports a verifyAccessToken helper. Use it in a middleware:
import { verifyAccessToken } from "../lib/auth";
import type { Request, Response, NextFunction } from "express";
export function requireAuth(req: Request, res: Response, next: NextFunction) {
const header = req.headers.authorization;
if (!header?.startsWith("Bearer ")) {
return res.status(401).json({ status: "error", code: "UNAUTHORIZED" });
}
try {
const payload = verifyAccessToken(header.slice(7));
(req as any).user = payload;
next();
} catch {
res.status(401).json({ status: "error", code: "TOKEN_EXPIRED" });
}
}Token Refresh Flow (JWT)
Access tokens expire quickly (default 15m). Use the refresh endpoint to get a new one without re-authenticating:
curl -X POST http://localhost:3000/api/auth/refresh \
-H "Content-Type: application/json" \
-d '{ "refreshToken": "<your-refresh-token>" }'Response:
{
"accessToken": "<new-access-token>",
"refreshToken": "<new-refresh-token>"
}Refresh tokens are rotated on each use — the old token is invalidated immediately. Store the latest refresh token securely (httpOnly cookie or secure local storage).
Troubleshooting
BETTER_AUTH_SECRET must be at least 32 characters
Generate a valid secret:
openssl rand -base64 32Cannot find module 'better-auth'
The auth module auto-installs dependencies, but if install failed mid-run:
npm install better-authJWT TokenExpiredError on every request
Your JWT_ACCESS_EXPIRES_IN may be too short or your system clock is out of sync. Verify:
node -e "console.log(new Date().toISOString())"Database migration fails after adding auth
Auth generates a schema file at src/db/schema/auth.ts. Run migrations after adding the module:
npx drizzle-kit generate
npx drizzle-kit migrateSession not persisting with Better Auth Ensure your frontend sends credentials with requests:
fetch('/api/auth/get-session', { credentials: 'include' });