Sign-up, OAuth & Invitations
Security
This page documents the security design decisions in restheart-accounts.
Timing-attack mitigation
All token comparisons (invite tokens, verification tokens, password reset tokens, OAuth state) use constant-time string comparison. This prevents an attacker from inferring valid token prefixes by measuring response latency.
Standard string equality (String.equals) short-circuits on the first mismatch and leaks timing information. restheart-accounts uses MessageDigest.isEqual on the SHA-256 digests of both values — a standard constant-time technique.
Account enumeration prevention
-
POST /auth/forgot-passwordalways returns202 Acceptedregardless of whether the email is registered, verified, or unverified. The response body is the same in all cases — internally it sends a password-reset email when the account exists and nothing otherwise, but the caller can’t tell which. -
The resend cooldown of
POST /auth/forgot-password(see below) does not change the response either: when it stops the email, the caller still gets the same202. -
POST /auth/registerreturns409 Conflictfor duplicate emails — this is a deliberate trade-off: registration UX requires telling the user the address is taken so they can recover their account.
Registering an address you do not own
POST /auth/register creates the account before the address is proven: anyone can register any email, with a password of their choosing, and the owner proves the address later by following the verification link. Two rules make sure that a password chosen before that proof never becomes a way into the account (since 9.13.0, GHSA-m7m7-gjxg-cjmm):
-
An OAuth sign-in that activates an unverified account clears its password first. The account signs in through the provider; the owner sets a password afterwards with
POST /auth/forgot-passwordorPATCH /auth/change-password. -
POST /auth/forgot-passwordnever re-sends the verification link. An unverified account gets the reset link, andPATCH /auth/reset-passwordverifies it while the owner sets a password of their own.
An account activated by the verification link itself keeps the password given at registration, since the person who registered and the person who followed the link are the same. An unverified account is never deleted: whatever the application stored in the user document at registration stays, and the owner reclaims the address through POST /auth/forgot-password.
Token design
All tokens (invite, verification, password reset, OAuth state) are:
-
Generated with
SecureRandom(256-bit entropy — equivalent to a UUIDv4 but longer). -
Single-use:
$unsetfrom the user document on first successful consumption. -
Scoped TTLs: invite and verification tokens expire after 7 days; password reset tokens after 1 hour; OAuth state after 600 seconds (MongoDB TTL index). The user document never expires: an unverified account keeps everything registered in it and is recovered through
POST /auth/forgot-password.
Password policy
Password strength is enforced server-side using the zxcvbn algorithm (Dropbox) at the point of:
-
Registration (
POST /auth/register) -
Invitation activation (
PATCH /auth/activate) -
Password reset (
PATCH /auth/reset-password)
The minimum score is configurable (minimumPasswordStrength, default 3 = "Strong").
Client-side zxcvbn checks are encouraged as UX guidance but are not trusted as security controls.
Session hygiene
Before issuing a JWT on invitation activation, the server clears any existing session. This prevents a logged-in user from accidentally activating an invitation as a different identity (session fixation variant).
Legal consent versioning
Consent tracking (terms & conditions, privacy policy versions) is not handled by the Accounts plugin. See Consents Management for guidance on implementing custom consent tracking. === Google OAuth security
-
PKCE (RFC 7636,
S256method) is used on every OAuth initiation. Thecode_verifieris never sent to the browser — it is server-side only. -
stateis validated with constant-time comparison on callback. -
oauth_codesdocuments auto-expire after 600 seconds via a MongoDB TTL index; they are also deleted immediately on use.
/users self-service write restriction
|
Caution
|
Since RESTHeart 9.6.0. |
When accountsInitializer is enabled, it unconditionally restricts what the generic MongoDB REST resource at /users can be used for, regardless of any ACL the application defines:
-
PUTandPOSTto/usersare always rejected. Creating or fully replacing a user document must go through a dedicatedrestheart-accountsendpoint (/auth/register,/auth/verify,/auth/activate,/auth/accept-invite, the OAuth callback, …), never the generic REST resource. -
PATCHto/usersis rejected if it touchespassword,roles,team,teams,sub,socialAuths,providerId,emailVerificationToken,emailVerificationCreatedAt,passwordResetToken,passwordResetCreatedAt,inviteToken, or_id. This is checked regardless of how the field is expressed — a full-document key, dot notation (team.role), or a MongoDB update operator ($set,$push,$addToSet, …) — so{"$set": {"teams.0.role": "owner"}}is rejected exactly like{"teams": […]}. Every other field —profile.*,consents, and any application-level field the tenant adds — is left entirely to the application’s own ACL.
Why: an ACL commonly grants an authenticated user permission to PATCH their own /users/{email} document, so they can self-edit their profile (name, avatar, …) or application-level data (consents, preferences, …). Without this restriction, that same permission would let the user rewrite any field on their own document — including roles, team/teams, or a directly-set password — which is a privilege-escalation path (e.g. promoting themselves to owner in a team, or joining a team they were never invited to). This restriction closes that path unconditionally, for every application built on restheart-accounts, independent of how permissive its own ACL is — while still letting applications use the generic REST resource for self-service data that carries no such risk.
This does not affect any restheart-accounts endpoint itself: every service that legitimately needs to set roles, team/teams, tokens, or a hashed password writes to MongoDB directly via the driver, not through the REST resource — so none of those flows are subject to this restriction.
If your application has its own reason to let certain accounts write more than profile.* to /users via the generic REST resource (for example, an internal admin console that manages roles/teams directly), configure users-unrestricted-roles in accountsConfig:
accountsConfig:
users-unrestricted-roles: [admin]
Accounts with any of the listed roles bypass this restriction entirely — both the PUT/POST block and the field denylist on PATCH. Leave it unset (the default) to apply the restriction to every caller, with no exceptions.
In a multi-tenant deployment, users-unrestricted-roles can also be overridden per team via override-accounts-users-unrestricted-roles — see Multi-tenancy for the full override mechanism.
accountsInitializer is enabled at the node level, independent of whether a given tenant has actually opted into restheart-accounts (e.g. Ulabase enables it once per service node, but Sign-up Management is an opt-in feature per service). A tenant that never enabled it never opted into these opinions on /users either — so a deployment-layer interceptor running before authentication (e.g. TeamConfigInterceptor, at REQUEST_BEFORE_EXCHANGE_INIT) can attach override-accounts-signup-mgmt-enabled: false to skip the restriction entirely for that tenant, for every caller. This must be set before authentication: the veto is evaluated as part of authorization, which runs before any REQUEST_AFTER_AUTH interceptor gets a chance to run.
JWT claims
Which user-document fields are copied into an issued JWT, the claims that always survive a per-tenant override, and the denylist that no configuration can bypass, are described in Authentication — JWT claims.
They are documented there rather than here because they are not specific to restheart-accounts:
the same JwtIssuer applies them to every token this deployment issues — on /token, from the
accounts endpoints, and to OAuth authorization codes alike.
Email abuse protection
Since 9.12.2 restheart-accounts limits how the emails it sends can be abused. The first two protections below are configured with the accountsEmailPolicy provider (see Configuration).
X-Skip-Email is for tests only
POST /auth/register, POST /auth/forgot-password, POST /auth/invite and POST /auth/resend-invite skip their email when the request carries X-Skip-Email: true, so that integration tests can run without an SMTP server. Honoured in production, the header lets anyone create an account, a password-reset token or an invitation for an address without its owner being told.
The header is therefore ignored unless allow-skip-email-header is true, and RESTHeart logs a warning at startup when it is. Turn it on only in the configuration of your test environment.
Resend cooldown
POST /auth/forgot-password is public. Without a limit, anyone can call it in a loop and flood an address with reset emails, also an address they registered themselves through POST /auth/register.
It now sends at most one email per address every resend-cooldown-seconds (default 300), counting the verification email sent at registration. Calls inside the cooldown send nothing and get the same 202.
Input that ends up in emails
The names given to POST /auth/register (firstName, lastName, teamName) appear in emails, in the greeting and in the invitation subject. They are limited to 100 characters, on one line, without control characters, < or >, so that they cannot add lines or markup to an email. The email field must be a single address. See User registration.
Brute-force protection
restheart-accounts relies on RESTHeart’s built-in bruteForceAttackGuard (sliding-window rate limiter per IP) for protection against token-guessing and credential stuffing.
Login endpoint
Failed login attempts on POST /token are counted automatically by the standard auth pipeline and trigger the guard.
Token-verification endpoints
Failed token checks on the following endpoints return 401 Unauthorized and are counted into the same AUTH metric registry via the built-in tokenFailedAuthInterceptor:
| Endpoint | Method | Counted when |
|---|---|---|
|
|
Invalid or expired invite token |
|
|
Invalid or expired reset token |
|
|
Redirect to |
Structural errors (missing fields, malformed JSON) still return 400 Bad Request and are not counted — only genuine token-guess failures feed the guard.
Configure bruteForceAttackGuard in restheart.yml:
/bruteForceAttackGuard/enabled: true
/bruteForceAttackGuard/max-failed-attempts: 5
/bruteForceAttackGuard/trust-x-forwarded-for: true # set false if no reverse proxy