Authentication
Last updated 2026-10-10
@rheajs/auth adds password hashing, JWT access tokens and route guards. It is alpha: the API can change, and it has had no independent security review.
npm install @rheajs/authIt needs @rheajs/core, which your project already has.
Add it to a new project#
With a database chosen, npx create-rhea my-api asks "Add authentication?". Or pass --db mysql --auth (or --db mongodb --auth). You get:
POST /api/auth/register,POST /api/auth/loginandGET /api/auth/me, insrc/modules/auth/.- A
userscollection (MongoDB, unique email index) or table (MySQL), created on start. JWT_SECRETgenerated into your.env..env.exampleleaves it empty, and the app refuses to start without 32 or more characters.rhea doctorchecks it.- Passwords of 12 to 128 characters. Emails are trimmed and lower-cased. Wrong password and unknown email give the same 401.
- Register and login limited to 10 requests per 15 minutes per IP, per process. Pass a shared store when you run several instances.
- An integration test that runs without a database.
New users get the role user. Nobody can register as admin: grant roles yourself in the database. Existing projects can install @rheajs/auth and follow the rest of this page by hand.
What it does and does not do#
Included:
- Password hashing with scrypt, in
node:crypto, with no native add-on to compile. - Short-lived signed access tokens (JWT, HS256) with issuer, audience and expiry checks.
- Middleware that requires a token, allows anonymous access, or requires a role.
Not included yet: refresh tokens, sessions, OAuth or OIDC, multi-factor authentication, password reset flows, account lockout, and asymmetric keys (RS256, EdDSA). Build those on top, or wait for later releases. Access tokens cannot be revoked before they expire, so keep them short.
Hash and verify passwords#
import { hashPassword, needsRehash, verifyPassword } from "@rheajs/auth";
export async function register(password: string): Promise<string> {
// Store the returned string in your database as is.
return hashPassword(password);
}
export async function checkLogin(password: string, stored: string | null): Promise<boolean> {
// Pass null when the email is unknown. It spends the same time as a real check.
const ok = await verifyPassword(password, stored);
if (ok && stored && needsRehash(stored)) {
// Save `await hashPassword(password)` over the old hash.
}
return ok;
}Details that matter:
- The hash string holds the algorithm, cost, salt and hash, so you can raise the cost later and
needsRehashtells you which users to upgrade at their next login. - The default cost is scrypt N=2^17, r=8, p=1 (the OWASP minimum for scrypt) and uses about 128 MiB per hash. Size your servers for it: many logins at once use a lot of memory. Add a login rate limit.
- Each hash in progress needs about 128 MiB of memory, and Node.js runs four at a time by default (
UV_THREADPOOL_SIZE). 24 simultaneous logins peaked at about 600 MiB of resident memory in a test. Give the container at least 1 GiB, and keep the login rate limit. - Passwords longer than 1024 characters are refused before hashing, so one huge input cannot burn CPU.
verifyPasswordnever throws for a wrong password or a damaged hash. It returnsfalse.- Visually identical Unicode passwords (for example
étyped two ways) match.
Issue and check tokens#
import { createJwt } from "@rheajs/auth";
export const jwt = createJwt({
secret: process.env["JWT_SECRET"]!,
issuer: "my-api",
audience: "my-api-clients",
expiresIn: "15m",
});
export const issueToken = () => jwt.sign({ sub: "user-123", roles: ["admin"] });secretmust be at least 32 bytes orcreateJwtthrows. Generate one withopenssl rand -base64 48, keep it in the environment, and never commit it. AddJWT_SECRET: z.string().min(32)to your environment schema so a bad deployment fails at startup.issuerandaudienceare required and checked on every token.- Only HS256 is accepted, so a token cannot pick its own algorithm.
alg: noneand other algorithms are rejected. verifythrows a 401UnauthorizedErrorwith codeTOKEN_EXPIREDorINVALID_TOKEN. The message never says which part was wrong.- A JWT is signed, not encrypted. Anyone with a token can read it. Do not put secrets or personal data in claims.
iss,aud,exp,iat,nbfandjtiin your claims are ignored, so the library controls them.
Protect routes#
import { Router, rateLimiter, sendSuccess } from "@rheajs/core";
import { authenticate, createJwt, requireRole } from "@rheajs/auth";
const jwt = createJwt({ secret: process.env["JWT_SECRET"]!, issuer: "my-api", audience: "my-api-clients" });
export const users = Router();
// Any signed-in user.
users.get("/me", authenticate(jwt), (req, res) => sendSuccess(res, req.user));
// Only admins or owners.
users.delete("/:id", authenticate(jwt), requireRole("admin", "owner"), (_req, res) => sendSuccess(res, { deleted: true }));
// Throttle login attempts.
users.post("/login", rateLimiter({ limit: 5, windowMs: 15 * 60_000 }), (_req, res) => sendSuccess(res, { ok: true }));authenticate(jwt)readsAuthorization: Bearer <token>and setsreq.userto{ id, roles, claims }. A missing token gives 401AUTH_REQUIRED. Tokens are never read from the URL, so they stay out of logs.optionalAuth(jwt)lets anonymous requests through but still rejects a token that is present and invalid.requireRole(...roles)gives 403 unless the user has at least one listed role. It must run afterauthenticate.rolesis only trusted if it is an array of strings. Anything else grants no roles.
Authentication answers who is calling. It does not decide what they may do with a specific record. Check ownership in your service code, for example that a user only edits their own profile.