Authentication
Sign-up, login, roles, refresh tokens and social sign-in on your own user model.
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.
Generate a secret
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))" # → put it in .env as JWT_SECRETAdd the auth fields to your user schema
See What your model needs below. The minimum is an identifier (
email),passwordandrole.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);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>. AfterrequireAuth,req.authis{ userId, role, claims }.requireRolemust come afterrequireAuth.
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#
| Method | Path | What it does | Who |
|---|---|---|---|
| POST | /register | Creates an account, returns a token. | Anyone |
| POST | /login | Signs in with any configured identifier. | Anyone |
| POST | /social/:provider | google, apple, facebook, github — Social sign-in. | Anyone |
| POST | /password/forgot | Sends a one-time code — Email & SMS. | Anyone |
| POST | /password/reset | Verifies the code, sets a new password. | Anyone |
| POST | /password/change | Changes the password, given the current one. | Signed in |
| POST | /token/refresh | Trades a refresh token for a new access token — Refresh tokens. | Anyone |
| POST | /logout · /logout/all | Ends one session · every session. | Anyone · Signed in |
| GET | /me | The signed-in user. | Signed in |
| GET | /users | Lists users, paginated, filterable by identifier or role. | Admin |
| GET | /users/:id | One user, without secrets. | Signed in |
| PATCH | /users/:id | Updates updateFields on yourself, or anyone if admin. | Self or admin |
| PATCH | /users/:id/roles | Assigns 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#
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.
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.
| Option | Env | Default |
|---|---|---|
refresh.enabled | AUTH_REFRESH_ENABLED | false |
refresh.rotate | AUTH_REFRESH_ROTATE | true |
refresh.expiresIn | AUTH_REFRESH_EXPIRES_IN | '30d' |
refresh.graceSeconds | — | 10 |
refresh.revokeAllOnReuse | — | true |
refresh.maxSessions | — | 5 |
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#
| Attempt | Result |
|---|---|
Registering with "role": "admin" | Stripped. Roles have their own admin-only endpoint. |
PATCH /users/:id with a password or role | Ignored. Each has its own endpoint. |
| Updating someone else's record | 403, unless admin. |
| Probing whether an account exists | Wrong password and unknown account get the same message and timing. |
| Guessing passwords | Counted 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 role | 400 — it could lock everyone out. |
| A token issued before a password change | 401. |
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#
| Option | Default | Controls |
|---|---|---|
model | required | Your Mongoose user model. |
token.secret | required | HMAC secret, 32+ characters. |
token.expiresIn | '15m' | Access-token lifetime: '15m', '7d' or seconds. |
token.issuer · token.audience | none | Set and checked on every token when given. |
token.invalidateOnPasswordChange | true | End sessions issued before a password change. |
identifiers | ['email'] | Fields a client may sign in with. identifier(field, normalize) for custom normalizing. |
fields | defaults above | Rename stored fields. fields.disabled names a boolean that blocks sign-in. |
roles | { default: 'user', admin: ['admin'] } | list, default, admin, field, multiple. |
registerFields · updateFields | none | Extra fields settable at sign-up · by users on themselves. |
password.minLength | 8 | Shortest accepted password. |
password.hash · password.verify | scrypt | Bring 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. |
refresh | off | Refresh tokens. |
appName | APP_NAME | {{appName}} in templates. |
mail · sms | SMTP_* if set · off | Code delivery — Email & SMS. |
social | none | Social sign-in. |
routes · middlewares | all mounted · none | Rename, remove and guard routes. |
onRegister · onLogin | none | (user, req) hooks. Throw HttpError from onLogin to refuse. |
maxLimit · defaultPageSize · maxTimeMS · countStrategy · lean | as on routers | Limits for GET /users. |
logger | console | { warn, error, debug? } |
Social sign-in#
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./social/:provider{ idToken }{ idToken, name? }{ accessToken }debug_tokenconfirms it was minted for your app.{ accessToken }or{ code }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: