Sign-up, OAuth & Invitations
User Registration & Email Verification
Flow Overview
sequenceDiagram
participant C as Client
participant R as RESTHeart
participant M as MongoDB
participant E as Email
C->>R: POST /auth/register
Note over R: validate fields, zxcvbn score ≥ 3
R->>M: check email uniqueness
R->>M: create user (roles: $unauthenticated + verify token)
M-->>R: OK
R->>E: verification email
R-->>C: 201 Created
Note over C,E: user clicks link in email
C->>R: GET /auth/verify?email=…&token=…
R->>M: constant-time token compare + TTL check
R->>M: set roles → user, unset verify token
M-->>R: OK
R-->>C: 302 + JWT cookie → frontend app URL
Registration flow
POST /auth/register is the public signup endpoint. It creates a new user and a new team in a single atomic operation.
Request
POST /auth/register
Content-Type: application/json
{
"firstName": "Alice",
"lastName": "Rossi",
"teamName": "Acme",
"email": "alice@acme.com",
"password": "correct-horse-battery"
}
Server-side steps
-
Validate all required fields. Since 9.12.2 the values must also be usable in an email:
-
email: a single address of at most 254 characters, without spaces, commas, semicolons, quotes, parentheses, square or angle brackets. -
firstName,lastNameandteamName: at most 100 characters each, without line breaks, control characters,<or>.
-
-
Enforce password strength via zxcvbn (score ≥
minimumPasswordStrength, default 3). -
Check email uniqueness —
409 Conflictif already registered. -
Generate a cryptographically random
verificationToken(256-bit) andverificationCreatedAttimestamp. -
Create the user document with
roles: ["$unauthenticated"]and the verification token. -
Validate the user document against the collection’s JSON Schema (if configured — see below).
-
Create a
membershipdocument linking the user to the team asowner. -
Send a verification email containing a one-time link:
{baseAppUrl}/auth/verify?email=alice@acme.com&token=<verificationToken> -
Return
201 Created.
The verification link expires after 7 days; the account and everything registered in its document stay. The owner recovers it at any time with POST /auth/forgot-password: the reset link sets a password and verifies the account in one step, see Password Reset. The verification link is never re-sent, since anyone can register any address and the password given at registration may not be the owner’s (since 9.13.0, GHSA-m7m7-gjxg-cjmm).
Response
{ "message": "Registration successful. Check your email to verify your address." }
Error responses
| Status | Reason |
|---|---|
|
Missing or invalid fields (including an email or a name rejected by the rules above); password too weak; document violates collection JSON Schema |
|
Email already registered. If the address is the caller’s own and the account was never verified, |
|
A JSON Schema is configured on the users collection but cannot be applied |
JSON Schema validation on registration
|
Note
|
Available from v9.7.0
Starting from RESTHeart v9.7.0, the registration endpoint validates the user document against the collection’s JSON Schema (if configured).
|
If the users collection has a jsonSchema metadata configured (via the _properties collection), the registration endpoint validates the user document against that schema before inserting it. This ensures that invariants declared on the users collection — for example that a consents field is always present — are enforced even for documents created through the registration endpoint, which bypasses the MongoService pipeline.
How it works
-
The user document is built from the request body (see the mapping table below).
-
The collection properties are read from
_properties._properties.{usersCollection}in the effective database. Both the database and the collection are sourced frommongoRealmAuthenticator(users-dbandusers-collection). -
If a
jsonSchemametadata is present, the document is validated against the referenced schema, resolved through thejson-schemasprovider. -
On validation failure, the endpoint returns
400with the list of violations, and no user is created. -
If no schema is configured, validation is skipped (opt-in).
Validation fails closed
Once the jsonSchema metadata is on the collection, it is honoured or the request fails. If the schema cannot be applied — it is missing from the schema store, the metadata is malformed, or restheart-mongodb is not deployed so the json-schemas provider is unavailable — the endpoint returns 500 and no user is created.
A typo in schemaId therefore surfaces as an error rather than silently disabling the invariant the schema was written to enforce.
What the schema sees
The document is validated as it is inserted, which is not its final shape. createInitialTeam runs right after the insert and adds team (the active team id, {_id}) to the user document. The membership is stored in the team’s members. Consequently:
-
a schema with
required: ["team"]always fails, even though the stored document does end up with the field; -
a schema with
additionalProperties: falsepasses validation but does not describe the document a moment later.
Schemas meant for the registration path should declare team as optional.
When no jsonSchema metadata is configured on the users collection, fields of the request body that are not in the mapping table are dropped before the document is built, so the schema never sees them and cannot reject them — with or without additionalProperties: false.
When a jsonSchema is configured, the remaining body properties are carried into the user document as-is before validation. The schema becomes the contract: additionalProperties: false rejects extensions, required: […] mandates them. Service-managed fields (_id, password, roles, profile.name, profile.surname, emailVerificationToken, emailVerificationCreatedAt) are never overwritten by the body.
Application-level fields such as consents should be declared optional in the schema — their absence is enforced by guard rules, not by the schema. The schema validates the format when the field is present. See Gating on Consents for the full pattern.
Violation messages are returned as-is, in the schema’s vocabulary. A body that omits firstName produces #/profile/name: required key [name] not found, not a message mentioning firstName.
Request body to user document mapping
| Request body field | User document field |
|---|---|
|
|
|
|
|
|
|
|
|
not stored on the user document — a team document is created by |
|
Note
|
The schema validates the stored document, not the request body. A schema that requires |
Example
# Create a schema that validates consents format (optional) and allows OAuth fields
http POST :8080/_schemas Authorization:"Basic ..." \
_id=user-with-consents \
'$schema'="http://json-schema.org/draft-07/schema#" \
type=object \
required:='["_id", "password", "roles", "profile"]' \
properties:='{
"profile": {
"type": "object",
"required": ["name", "surname"],
"properties": {
"name": { "type": "string" },
"surname": { "type": "string" },
"avatarUrl": { "type": "string" }
}
},
"consents": {
"type": "object",
"required": ["terms", "privacy"],
"properties": {
"terms": { "type": "boolean" },
"privacy": { "type": "boolean" }
}
},
"socialAuths": { "type": "array" },
"team": { "type": "object" }
}'
# Apply schema to users collection
http PATCH :8080/users Authorization:"Basic ..." \
jsonSchema:='{"schemaId":"user-with-consents"}'
# Registration with consents in the body — passes validation
http POST :8080/auth/register Authorization:"Basic ..." \
firstName=Alice lastName=Smith teamName=Acme \
email=alice@example.com password=Password123! \
consents:='{"terms":true,"privacy":true}'
# → 201 Created (consents stored on the user document)
# Registration without consents — schema is fine, guard handles absence
http POST :8080/auth/register Authorization:"Basic ..." \
firstName=Bob lastName=Jones teamName=Acme \
email=bob@example.com password=Password123!
# → 201 Created (no consents field — guard rule blocks subsequent requests)
|
Note
|
Consents management: User consent tracking is not handled by the Accounts plugin. See Consents Management for details. |
Email verification
GET /auth/verify?email=…&token=… is sent as a link in the registration email.
Server-side steps
-
Extract
email,tokenand the optionaldeliveryparameter from the query string. -
Find the user by email.
-
Compare
tokenagainstverificationTokenusing constant-time comparison (timing-attack mitigation). -
Check that
verificationCreatedAtis within the TTL (verificationTokenTtlDays, default 7 days). -
Assign the system ACL role from
accountsConfig.default-role(default:user) — this replaces the$unauthenticatedrole assigned at registration.$unsetverificationToken and verificationCreatedAt. -
Issue a JWT and deliver it according to
delivery(see below). -
Redirect to
{frontendAppUrl}.
Token delivery: cookie vs URL fragment
The optional delivery query parameter selects how the JWT is handed to the frontend:
delivery |
Behavior |
|---|---|
|
Default. Sets the auth cookie and redirects to |
|
Redirects to |
GET /auth/verify?email=...&token=... → cookie (default)
GET /auth/verify?email=...&token=...&delivery=cookie → cookie
GET /auth/verify?email=...&token=...&delivery=fragment → URL fragment
With delivery=fragment no cookie is set. The fragment uses the same mechanism as GET /token/redirect and the OAuth callback: since a URL fragment is never sent to any server, the token does not leak into access logs or the Referer header. The frontend reads it from window.location.hash and stores it (e.g. in localStorage) for use in the Authorization: Bearer header.
Expired or invalid token
The request arrives from a link in an email, so every outcome is a redirect: an expired token sends the browser to {frontendUrl}/auth/login?error=token_expired, an unknown token or a mismatched email to {frontendUrl}/auth/login?error=invalid_token.
The way out is POST /auth/forgot-password: it sends a reset link that sets a password and verifies the account in one step (since 9.13.0). The verification link itself is never re-sent, and the account is never deleted for not following it.