controller-sets v3.3.0

Setup#

createAuthRouter adds sign-up, login, password reset, roles and user management to your user model. It defines no schema of its own, and needs no extra packages.

  1. Generate a secret

    node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
    # → put it in .env as JWT_SECRET
  2. Add the auth fields to your user schema

    See What your model needs below. The minimum is an identifier (email), password and role.

  3. Mount the router

    import { createAuthRouter } from 'express-controller-sets';
    import User from './models/User.js';
    
    const auth = createAuthRouter({
      model: User,
      token: { secret: process.env.JWT_SECRET, expiresIn: '15m' },   // secret: 32+ characters
      identifiers: ['email'],                                        // or ['email', 'phone']
      roles: { list: ['user', 'admin'], default: 'user' },
      registerFields: ['name'],                                      // extra fields at sign-up
      updateFields: ['name'],                                        // fields users may change on themselves
    });
    
    app.use('/api/auth', auth);
  4. Guard your other routes with it

    createRouter({ model: Note, middlewares: [auth.requireAuth], allowedFields: ['title', 'body'] });
    createRouter({ model: Report, middlewares: [auth.requireAuth, auth.requireRole('admin')], allowedFields: [] });
    
    app.get('/api/dashboard', auth.requireAuth, (req, res) => {
      res.json({ success: true, data: { userId: req.auth.userId, role: req.auth.role } });
    });

    Clients send Authorization: Bearer <token>. After requireAuth, req.auth is { userId, role, claims }. requireRole must come after requireAuth.

POST /api/auth/register   { "email": "ada@example.com", "password": "…", "name": "Ada" }
POST /api/auth/login      { "identifier": "ada@example.com", "password": "…" }
→ { "success": true, "data": { "token": "eyJ…", "expiresIn": 900, "user": { … } } }

What your model needs#

These are the default field names; rename any through the fields option (fields: { password: 'passHash' }). Mark secrets select: false — the library selects them explicitly where needed and never returns them.

const userSchema = new mongoose.Schema({
  // Identifiers — whichever you list in `identifiers`
  email: { type: String, unique: true, sparse: true },
  phone: { type: String, unique: true, sparse: true },

  password: { type: String, select: false },
  role: { type: String, default: 'user' },
  passwordChangedAt: Date,

  // Social sign-in — only the providers you enable
  googleId: String, appleId: String, facebookId: String, githubId: String,

  // Password-reset codes
  otpHash:      { type: String, select: false },
  otpPurpose:   { type: String, select: false },
  otpExpiresAt: { type: Date,   select: false },
  otpAttempts:  { type: Number, default: 0, select: false },

  // Lockout
  failedLoginAttempts: { type: Number, default: 0, select: false },
  lockedUntil:         { type: Date,   select: false },

  // Only with refresh tokens enabled
  refreshTokens: {
    type: [{ id: String, hash: String, previousHash: String, rotatedAt: Date,
             createdAt: Date, lastUsedAt: Date, expiresAt: Date, userAgent: String }],
    select: false,
  },
}, { timestamps: true });

Endpoints#

MethodPathWhat it doesWho
POST/registerCreates an account, returns a token.Anyone
POST/loginSigns in with any configured identifier.Anyone
POST/social/:providergoogle, apple, facebook, github — Social sign-in.Anyone
POST/password/forgotSends a one-time code — Email & SMS.Anyone
POST/password/resetVerifies the code, sets a new password.Anyone
POST/password/changeChanges the password, given the current one.Signed in
POST/token/refreshTrades a refresh token for a new access token — Refresh tokens.Anyone
POST/logout · /logout/allEnds one session · every session.Anyone · Signed in
GET/meThe signed-in user.Signed in
GET/usersLists users, paginated, filterable by identifier or role.Admin
GET/users/:idOne user, without secrets.Signed in
PATCH/users/:idUpdates updateFields on yourself, or anyone if admin.Self or admin
PATCH/users/:id/rolesAssigns roles from roles.list.Admin

The refresh and logout routes exist only when refresh tokens are enabled.

Refresh tokens#

Keep access tokens short (15 minutes) and sessions long (30 days). Add the refreshTokens field from the schema above, then enable it in code or .env:

createAuthRouter({ /* … */ refresh: { enabled: true, rotate: true, expiresIn: '30d' } });
AUTH_REFRESH_ENABLED=true
AUTH_REFRESH_ROTATE=true
AUTH_REFRESH_EXPIRES_IN=30d
POST /api/auth/login           → { "token": "…", "refreshToken": "…", "refreshExpiresIn": 2592000, … }
POST /api/auth/token/refresh   { "refreshToken": "…" }   → new token (and a new refreshToken when rotating)
POST /api/auth/logout          { "refreshToken": "…" }   → ends this session
POST /api/auth/logout/all      Authorization: Bearer …   → ends every session

Rotation on or off#

rotate: true · default

A new token on every refresh

The old one stops working. If it's presented again, someone is replaying it — that session ends, and by default every other session of that user. Choose this for browsers and anywhere a token might leak.

rotate: false

Same token until it expires

Simpler for clients that can't reliably store a new token each time. Still revocable by logout and password change. Choose this for trusted server-to-server clients.

Two tabs refreshing at once isn't an attack: a token rotated away in the last graceSeconds (10) is still accepted.

Client code#

Keep both tokens; on a 401, refresh once and retry. Save the refresh token you get back every time — it changes when rotating — and share one in-flight refresh between concurrent requests.

let access = null;
let refreshing = null;

async function refresh() {
  refreshing ??= fetch('/api/auth/token/refresh', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ refreshToken: localStorage.getItem('refreshToken') }),
  })
    .then(async (res) => {
      if (!res.ok) throw new Error('signed out');
      const { data } = await res.json();
      access = data.token;
      localStorage.setItem('refreshToken', data.refreshToken);   // new one when rotating
    })
    .finally(() => { refreshing = null; });
  return refreshing;
}

export async function api(path, init = {}) {
  const send = () => fetch(path, { ...init, headers: { ...init.headers, Authorization: `Bearer ${access}` } });
  let res = await send();
  if (res.status === 401) {
    await refresh();          // throws → send the user to sign-in
    res = await send();
  }
  return res;
}

In a browser, an httpOnly cookie set by your own endpoint is safer than localStorage for the refresh token; mobile apps should use the Keychain or Keystore.

How sessions are protected#

  • Refresh tokens are opaque random values, not JWTs. Only an HMAC of each is stored, so a leaked database holds no usable token.
  • Password change and reset revoke every refresh token.
  • Sessions are capped at maxSessions (5) per user; the oldest is dropped.
  • Every failure is the same 401 — unknown, expired, tampered and reused are indistinguishable.
  • A refresh re-reads the user, so a role change or disabled account takes effect at the next refresh.
OptionEnvDefault
refresh.enabledAUTH_REFRESH_ENABLEDfalse
refresh.rotateAUTH_REFRESH_ROTATEtrue
refresh.expiresInAUTH_REFRESH_EXPIRES_IN'30d'
refresh.graceSeconds—10
refresh.revokeAllOnReuse—true
refresh.maxSessions—5

Social sign-in#

createAuthRouter({
  // …
  social: {
    google:   { clientId: process.env.GOOGLE_CLIENT_ID },
    apple:    { clientId: process.env.APPLE_CLIENT_ID },
    facebook: { appId: process.env.FB_APP_ID, appSecret: process.env.FB_APP_SECRET },
    github:   { clientId: process.env.GH_ID, clientSecret: process.env.GH_SECRET },
  },
});

Add the matching googleId, appleId… fields to your schema. The client does the provider's sign-in flow, then posts the credential; the response is the same as /login.

ProviderClient sends to /social/:providerHow it's verified
Google{ idToken }RS256 signature against Google's keys, then issuer and audience.
Apple{ idToken, name? }The same, against Apple's keys. Apple sends a name only on first authorization, so forward it.
Facebook{ accessToken }debug_token confirms it was minted for your app.
GitHub{ accessToken } or { code }A code is exchanged server-side; the verified primary email is fetched.

An unconfigured provider is a 404. Sign-in matches on the provider's account id first, then falls back to a verified email, so a social login links to an account that registered by password.

Any other provider is one function:

social: {
  discord: {
    idField: 'discordId',
    verify: async ({ accessToken }) => {
      const profile = await fetchDiscordUser(accessToken);
      return { id: profile.id, email: profile.verified ? profile.email : null };
    },
  },
}

Sign in with email or phone#

List several identifiers and clients send whichever they have as identifier. Each is normalized, so a phone number reaches the same account however it's typed:

POST /auth/login  { "identifier": "Ada@Example.com",   "password": "…" }
POST /auth/login  { "identifier": "+1 (555) 010-1234", "password": "…" }   # same account as
POST /auth/login  { "identifier": "+15550101234",      "password": "…" }

Email is lowercased, phone loses its formatting, anything else is trimmed. Custom normalizer: identifiers: ['email', identifier('nationalId', (v) => v.toUpperCase())].

Rename, remove and guard routes#

const auth = createAuthRouter({
  model: User,
  token: { secret: process.env.JWT_SECRET },

  routes: {
    register: '/signup',        // rename
    login: '/signin',
    social: false,              // don't mount
    modifyRoles: false,
  },

  middlewares: {
    all: [cors()],              // every auth route
    login: [rateLimiter],       // just these
    forgotPassword: [rateLimiter],
  },
});

Route names: register, login, social, forgotPassword, resetPassword, changePassword, refresh, logout, logoutAll, me, listUsers, getUser, updateUser, modifyRoles. An unknown name throws at startup (and won't compile in TypeScript). Route middleware runs before the guard, so a limiter sheds load before any database work. auth.urls lists what was mounted; AUTH_ROUTES lists everything that can be.

Rate-limit sign-in#

Accounts lock after repeated failures, but that doesn't stop one password being tried against many accounts. Limit the routes that get guessed at:

import rateLimit from 'express-rate-limit';

const tight = rateLimit({ windowMs: 15 * 60 * 1000, limit: 20 });

createAuthRouter({
  // …
  middlewares: { login: [tight], register: [tight], forgotPassword: [tight], resetPassword: [tight] },
});

What it refuses#

AttemptResult
Registering with "role": "admin"Stripped. Roles have their own admin-only endpoint.
PATCH /users/:id with a password or roleIgnored. Each has its own endpoint.
Updating someone else's record403, unless admin.
Probing whether an account existsWrong password and unknown account get the same message and timing.
Guessing passwordsCounted on the record; the account locks. Holds across restarts and instances.
{ "password": { "$ne": null } }400. An object where a credential belongs is injection.
An admin removing their own admin role400 — it could lock everyone out.
A token issued before a password change401.

token.secret is the whole system. It signs every session and keys every one-time code. 32+ characters, never in the repository. Rotating it signs everyone out — which is what you want the day it leaks.

Not included: rate limiting beyond per-account lockout (add a limiter), and an email-verification flow.

Complete example#

Email and password, short access tokens with rotating refresh tokens, password reset by email, and a welcome mail.

import nodemailer from 'nodemailer';
import { createAuthRouter } from 'express-controller-sets';

const transporter = nodemailer.createTransport(process.env.SMTP_URL);

export const auth = createAuthRouter({
  model: User,
  identifiers: ['email'],
  token: { secret: process.env.JWT_SECRET, expiresIn: '15m' },
  refresh: { enabled: true, rotate: true, expiresIn: '30d' },
  roles: { list: ['user', 'admin'], default: 'user' },
  registerFields: ['name'],
  updateFields: ['name'],

  appName: 'Acme',
  mail: { transporter, from: 'Acme <no-reply@acme.com>' },

  onRegister: async (user) => {
    await transporter.sendMail({
      from: 'Acme <no-reply@acme.com>', to: user.email,
      subject: 'Welcome to Acme', text: `Hi ${user.name ?? 'there'}, thanks for signing up.`,
    });
  },
});

app.use('/api/auth', auth);

All options#

OptionDefaultControls
modelrequiredYour Mongoose user model.
token.secretrequiredHMAC secret, 32+ characters.
token.expiresIn'15m'Access-token lifetime: '15m', '7d' or seconds.
token.issuer · token.audiencenoneSet and checked on every token when given.
token.invalidateOnPasswordChangetrueEnd sessions issued before a password change.
identifiers['email']Fields a client may sign in with. identifier(field, normalize) for custom normalizing.
fieldsdefaults aboveRename stored fields. fields.disabled names a boolean that blocks sign-in.
roles{ default: 'user', admin: ['admin'] }list, default, admin, field, multiple.
registerFields · updateFieldsnoneExtra fields settable at sign-up · by users on themselves.
password.minLength8Shortest accepted password.
password.hash · password.verifyscryptBring bcrypt or argon2.
lockout{ maxAttempts: 10, lockSeconds: 900 }Failed sign-ins before lock, and for how long.
otp{ length: 6, ttlSeconds: 600, maxAttempts: 5 }One-time code policy.
refreshoffRefresh tokens.
appNameAPP_NAME{{appName}} in templates.
mail · smsSMTP_* if set · offCode delivery — Email & SMS.
socialnoneSocial sign-in.
routes · middlewaresall mounted · noneRename, remove and guard routes.
onRegister · onLoginnone(user, req) hooks. Throw HttpError from onLogin to refuse.
maxLimit · defaultPageSize · maxTimeMS · countStrategy · leanas on routersLimits for GET /users.
loggerconsole{ warn, error, debug? }
Esc
↑ ↓ to move↵ to open