Authentication

Projects created from the api template have accounts, sessions and password flows under /api/auth. The code is in calm/auth/, so you can read and change every decision. For two-factor authentication, add the totp block.

Auth turns on when config.auth.enabled is true (the default) and the project has a user resource with an email field and an f.password() field. The minimal template has no user resource and therefore no auth.

Endpoints#

Method and pathPolicyBodySuccess
POST /api/auth/registerpublic, rate limiteduser fields (name, email, password...)201 { data: { user, token, expiresAt } }
POST /api/auth/loginpublic, rate limited{ email, password }200 { data: { user, token, expiresAt } }
POST /api/auth/logoutauthenticated204, ends this session
POST /api/auth/logout-allauthenticated204, ends every session of the user
GET /api/auth/meauthenticated200 { data: user }
PATCH /api/auth/meauthenticatedprofile fields200 { data: user }
POST /api/auth/passwordauthenticated, rate limited{ currentPassword, newPassword }204, other sessions end
POST /api/auth/password/forgotpublic, rate limited{ email }202, always the same answer
POST /api/auth/password/resetpublic, rate limited{ token, password }204, every session ends
GET /api/auth/sessionsauthenticated200 { data: [session...] }
DELETE /api/auth/sessions/:sessionIdauthenticated204
POST /api/auth/devicesauthenticated{ token, platform, name?, appVersion?, locale? }201 { data: device }, registers a device for push notifications
GET /api/auth/devicesauthenticated200 { data: [device...] }
DELETE /api/auth/devices/:deviceIdauthenticated204
POST /api/auth/devices/unregisterauthenticated{ token }204, stops push notifications to the device holding that token
POST /api/auth/devices/openedauthenticated{ pushId?, data? }204, reports a tapped notification (emits push.opened)

Register#

Shell
curl -X POST http://localhost:5001/api/auth/register -H 'Content-Type: application/json' \
  -d '{"name":"Ada Lovelace","email":"ada@example.com","password":"correct-horse-battery"}'
JSON
{
  "data": {
    "user": { "id": "6acb46a57e4a738ebdd998b1", "name": "Ada Lovelace", "email": "ada@example.com", "roles": ["user"], "status": "active", "createdAt": "2026-10-11T08:19:49.069Z", "updatedAt": "2026-10-11T08:19:49.069Z" },
    "token": "calm_M_vAh-pIohIgNEe8gapu1LMoYxlH6oYvepNVGWtYPWk",
    "expiresAt": "2026-11-10T08:19:49.085Z"
  }
}

The body is validated against the user resource, like POST /api/users, but with no signed-in user: fields protected by writableBy('admin') (roles, status) are rejected with 422. A duplicate email is 409 CONFLICT. A password shorter than 8 characters, a common one, or one that contains the user's email or name is 422 (see Password rules). Set auth.register: false to close sign-ups (403).

Login#

Shell
curl -X POST http://localhost:5001/api/auth/login -H 'Content-Type: application/json' \
  -d '{"email":"ada@example.com","password":"correct-horse-battery"}'

Same response shape as register. A wrong email and a wrong password both answer 401 with Invalid email or password, after the same amount of work, so the response does not reveal whether an account exists. A user whose status is disabled (or deleting, while the privacy block erases the account) gets 403. After 5 failed attempts for the same email, sign-ins for it answer 429 for 15 minutes (see Account lockout).

With the totp block, users who turned on two-factor authentication get { mfaRequired: true, mfaToken, methods, expiresAt } instead of a session, and finish with POST /api/auth/2fa/verify.

Use the token#

Send it as a bearer token on every request:

Shell
curl http://localhost:5001/api/auth/me -H "Authorization: Bearer calm_M_vAh-pIohIgNEe8gapu1LMoYxlH6oYvepNVGWtYPWk"

Only the Authorization header is read: tokens in query strings or cookies are ignored. A malformed, expired or revoked token answers 401 Session is invalid or has expired on every route, public ones included, so clients notice a dead session instead of silently browsing as a visitor.

Profile#

PATCH /api/auth/me updates your own user with the user resource rules: email is immutable, roles and status are admin only, and the password cannot be changed here (use /api/auth/password).

Shell
curl -X PATCH http://localhost:5001/api/auth/me -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' -d '{"name":"Ada King"}'

Change password#

Shell
curl -X POST http://localhost:5001/api/auth/password -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' -d '{"currentPassword":"correct-horse-battery","newPassword":"a-much-longer-passphrase"}'

204 on success. A wrong current password is 422 with details.currentPassword. Every other session of the user is revoked; the current one stays signed in.

Sessions#

Shell
curl http://localhost:5001/api/auth/sessions -H "Authorization: Bearer $TOKEN"
JSON
{
  "data": [
    { "id": "6acb46a57e4a738ebdd998b3", "current": true, "createdAt": "2026-10-11T08:19:49.389Z", "lastUsedAt": "2026-10-11T08:19:49.388Z", "expiresAt": "2026-11-10T08:19:49.388Z", "ip": "127.0.0.1", "userAgent": "curl/8.5.0" },
    { "id": "6acb46a57e4a738ebdd998b2", "current": false, "createdAt": "2026-10-11T08:19:49.087Z", "lastUsedAt": "2026-10-11T08:19:49.085Z", "expiresAt": "2026-11-10T08:19:49.085Z", "ip": "127.0.0.1", "userAgent": "curl/8.5.0" }
  ]
}

DELETE /api/auth/sessions/:sessionId revokes one of your sessions (for a "sign out other devices" screen), POST /api/auth/logout-all revokes all of them. Ending a session also stops push notifications to the devices it registered.

Password reset#

  1. The user asks for a reset link:

    Shell
    curl -X POST http://localhost:5001/api/auth/password/forgot -H 'Content-Type: application/json' -d '{"email":"ada@example.com"}'
    JSON
    { "data": { "message": "If that email is registered, a reset link is on its way." } }

    The answer (202) is identical whether or not the email exists, and the work happens in the background so timing does not tell either.

  2. CalmAPI emails a link to auth.resetUrl (default ${APP_URL}/reset-password) with a token query parameter. The link is valid for auth.resetTokenTtlMinutes (30). In development the email lands in the devtools Mail panel; in production you need a mail block (Resend or SMTP).

  3. Your frontend's reset page reads the token and posts the new password:

    Shell
    curl -X POST http://localhost:5001/api/auth/password/reset -H 'Content-Type: application/json' \
      -d '{"token":"reset_f9l4o-ANKiqUMnW512f5ksuDjZLNh1GwNJSDXCIRGwc","password":"a-brand-new-password"}'

    204 on success. Every session of the user is revoked and other pending reset links stop working. An unknown, expired or already used token answers:

    JSON
    { "error": { "code": "INVALID_TOKEN", "message": "This reset link is invalid or has expired", "requestId": "..." } }

Rate limits#

Register, login, password change, forgot and reset share the auth limiter: 10 requests per 15 minutes per client IP and endpoint (rateLimit.auth). Every /api route also has a global limit of 300 requests per minute per IP (rateLimit.global). Responses carry RateLimit-Limit, RateLimit-Remaining and RateLimit-Reset; over the limit you get 429 RATE_LIMITED with Retry-After.

Limits are counted per process in memory. Behind a proxy set TRUST_PROXY so the real client IP is used, and with several instances add rate-limit-mongo. Rate limiting is off in tests (mode: 'test').

Account lockout#

Rate limits count requests per IP. Lockout counts failed sign-ins per account, so guesses spread over many IPs still hit it. After 5 failed sign-ins for the same email within 15 minutes, that email is locked for 15 minutes. While it is locked, every sign-in answers 429, even with the right password:

JSON
{ "error": { "code": "RATE_LIMITED", "message": "Too many failed sign-in attempts. Try again in 15 minutes.", "requestId": "..." } }
  • Attempts are counted per email address whether or not an account exists, so a lock does not reveal which emails are registered.
  • A successful sign-in clears the count, and so does a password reset.
  • Attempts are stored in calm_login_attempts, keyed by a hash of the email, and expire on their own (TTL index).
  • A failed sign-in emits security.login_failed, a new lock security.account_locked, and a sign-in during a lock security.locked_login_attempt (see Security events).

Change the numbers with auth.lockout (see Configuration); maxAttempts: 0 turns lockout off.

Password rules#

f.password() fields accept 8 to 128 characters. There are no composition rules ("one digit, one symbol"), but passwords that are easy to guess are refused with 422:

RuleMessage
One of the most common leaked passwords (password123, qwerty123, iloveyou...)This password is too common, choose another one
One repeated character or a straight run (aaaaaaaa, 23456789, abcdefghij)This password is too easy to guess
Contains the part of the email before the @ (when it has 4 or more characters)Your password must not contain your email address
Contains a word of the user's name (4 or more characters)Your password must not contain your name

The first two rules apply wherever a password field is validated. The email and name rules apply on register, password change and reset. The list of common passwords is in calm/auth/password-policy.js; add your own words (your product name, for example) there.

Configuration#

JavaScript
// calm.config.js
export default defineConfig({
  auth: {
    enabled: true,
    register: true,               // false: no public sign-up
    sessionTtlDays: 30,
    resetTokenTtlMinutes: 30,
    resetUrl: 'https://app.example.com/reset-password', // default: APP_URL + /reset-password
    password: { logN: 15, r: 8, p: 3 },                 // scrypt cost
    lockout: { maxAttempts: 5, windowMinutes: 15, lockMinutes: 15 }, // per account, 0 disables
  },
  rateLimit: { auth: { windowMs: 15 * 60_000, max: 10 } },
});

Design choices#

  • Opaque session tokens, not JWT. A token is 32 random bytes (calm_...). Only its SHA-256 hash is stored (collection calm_sessions), so a database leak does not leak usable sessions. There is no signing secret to configure, rotate or leak, and logout really ends a session. The price is one indexed lookup per request.
  • scrypt from node:crypto. No native addon to compile. Parameters follow the OWASP cheat sheet (N=2^15, r=8, p=3) and are stored inside each hash, so raising auth.password later upgrades users transparently the next time they log in.
  • No account enumeration. Login spends the same time for unknown emails, forgot always answers the same, and lockout counts unknown emails like real ones.
  • Single-use reset tokens. Random, hashed at rest (collection calm_password_resets), claimed atomically, short-lived, and they revoke every session when used.
  • Expiry is automatic. Both collections have TTL indexes, so MongoDB deletes expired sessions and tokens itself. lastUsedAt is refreshed at most every 5 minutes.
  • Disabled accounts stop at once. Setting status: 'disabled' rejects their existing sessions on the next request.

In your code#

ctx.user is the signed-in user (the stored record with id as a string, without the password hash), ctx.session is { id, expiresAt }. The auth service is available as app.auth.service (for example app.auth.service.createSession(user) in a script). Events: auth.registered, auth.login, auth.password_changed, auth.password_reset_requested, auth.password_reset, plus the security.* events for failed sign-ins, lockouts and bad tokens.

Two-factor authentication is available as the totp block, passwordless sign-in by email as the magic-link block, and API keys for scripts as the api-keys block. They plug into login with the loginChallenge hook, the shared auth service helpers (lockout, disabled accounts, completeLogin) and calm.authenticate for other credentials, which you can use for your own sign-in methods too. There are no social logins built in. Since calm/auth/ is your code, you can add them, and the session model stays the same.