Account Recovery and Activation
Activation mails, password reset and one-time passwords.
This page describes the flows that let users prove they own their email address and get back into their account:
- Account activation: new users confirm their email address before they can sign in.
- Password reset: users who forgot their password get a reset link by email.
- One-time passwords: users get a short code through a channel of your choice (SMS, a call center…) and exchange it for a password reset.
All three end with a page of your own (an activation page, a reset password page) and its backend, which calls ErtisAuth. ErtisAuth doesn't host these pages.
How the links work#
Activation and reset mails contain a link to your page. You give the page's URL in the X-Host header, and ErtisAuth appends a query parameter with a code:
| Flow | Link |
|---|---|
| Activation | {X-Host}?uat=<code> |
| Password reset | {X-Host}?rpt=<code> |
For example, with X-Host: https://app.example.com/reset-password the reset mail links to https://app.example.com/reset-password?rpt=NjZmMWMw….
The code is base64 of <membership_id>:<token>. Treat it as opaque: your page reads it from the query string and passes it back to ErtisAuth unchanged.
The codes are single-use and short-lived, and they can't be used as access tokens.
Who calls these endpoints#
The endpoints of these flows are protected like any other endpoint (see the permissions below), because the person on your page is not signed in. Call them from your page's backend with an application's Basic token, and give that application's role only what the flows need:
{
"name": "Account Pages",
"slug": "account-pages",
"permissions": [ "users.read", "users.update", "users.create" ]
}Never put the application's secret in the browser.
Account activation#
When a membership's user_activation is active, new users are created inactive and can't sign in (401 UserInactive) until they click the link in their activation mail.
Setup#
- Add a mail provider to the membership (
mail_providers, see Memberships). - Create a mail hook named exactly
User Activation, for the eventUserCreated, with statusactive. Use{{activationLink}}in its template:The template receives{ "name": "User Activation", "event": "UserCreated", "status": "active", "mailProvider": "company-smtp", "fromName": "My Company", "fromAddress": "no-reply@example.com", "sendToUtilizer": false, "recipients": [ { "displayName": "{{user.firstname}}", "emailAddress": "{{user.email_address}}" } ], "mailSubject": "Activate your account", "mailTemplate": "<p>Hi {{user.firstname}},</p><p><a href=\"{{activationLink}}\">Activate your account</a></p>" }user(the new user) andactivationLink. - Set
user_activationtoactiveon the membership.
Without a mail provider or the activation mail hook, creating users fails with 501 NotDefinedAnyMailProvider or 501 ActivationMailHookWasNotDefined, before anything is created.
Flow#
- Create the user with the
X-Hostheader set to your activation page:The user is created inactive and the activation mail is queued. IfPOST /memberships/{membershipId}/users X-Host: https://app.example.com/activateX-Hostis missing, the user is still created but no mail is sent; resend it later. - The user clicks the link and lands on
https://app.example.com/activate?uat=<code>. - Your backend activates the account:Permission:
GET /memberships/{membershipId}/users/activation?uat=<code> Authorization: Basic <application_id>:<secret>users.update. Response200 OKwith the activated user.
The activation code is valid for 72 hours and only once. It also stops working when the user is changed in the meantime, for example frozen.
| Error | When |
|---|---|
401 InvalidToken | The code is malformed, expired, already used, or of another membership |
400 UserAlreadyActive | The user is already active |
Resend the activation mail#
POST /memberships/{membershipId}/users/resend-activation-mail
X-Host: https://app.example.com/activate
Content-Type: application/json
{ "email_address": "ada@example.com" }Permission: users.create. Response 200 OK:
{ "emailAddress": "ada@example.com" }| Error | When |
|---|---|
400 HostRequired, 400 EmailAddressRequired | A required value is missing |
400 UserAlreadyActive | Nothing to activate |
404 UserNotFound | No user with this email address |
501 NotDefinedAnyMailProvider, 501 ActivationMailHookWasNotDefined | Mails can't be sent |
Activating without a mail#
An administrator can activate a user directly with GET /users/{id}/activate.
Password reset#
Setup#
- Add a mail provider to the membership.
- Create a mail hook named exactly
Reset Password, for the eventUserPasswordReset, with statusactive. Use{{resetPasswordLink}}in its template:The template receives{ "name": "Reset Password", "event": "UserPasswordReset", "status": "active", "mailProvider": "company-smtp", "fromName": "My Company", "fromAddress": "no-reply@example.com", "recipients": [ { "displayName": "{{user.firstname}}", "emailAddress": "{{user.email_address}}" } ], "mailSubject": "Reset your password", "mailTemplate": "<p>Hi {{user.firstname}},</p><p><a href=\"{{resetPasswordLink}}\">Choose a new password</a>. The link expires soon.</p>" }userandresetPasswordLink. - Optionally set
reset_password_token_expires_inon the membership (2 hours by default).
1. Request the reset#
Your "forgot password" page's backend calls:
POST /memberships/{membershipId}/users/reset-password
Authorization: Basic <application_id>:<secret>
X-Host: https://app.example.com/reset-password
Content-Type: application/json
{ "email_address": "ada@example.com" }Permission: users.update. Response 200 OK:
{
"message": "Reset token generated",
"expiresIn": 7200
}The mail is queued and a UserPasswordReset event is recorded.
| Error | When |
|---|---|
400 HostRequired, 400 EmailAddressRequired | A required value is missing |
401 UserInactive | The account is inactive or frozen: it can't be recovered this way |
404 UserNotFound | No user with this email address |
501 NotDefinedAnyMailProvider, 501 ResetPasswordMailHookWasNotDefined | Mails can't be sent |
404. To avoid revealing which addresses have accounts, show the same message to the person in both cases ("If an account exists, we sent you an email").2. Verify the link#
When the user opens https://app.example.com/reset-password?rpt=<code>, check the code before you show the form:
GET /memberships/{membershipId}/users/verify-reset-token?token=<code>
Authorization: Basic <application_id>:<secret>Permission: users.read. Response 200 OK:
{ "email_address": "ada@example.com" }An invalid, expired or used code answers 401 InvalidToken.
3. Set the new password#
POST /memberships/{membershipId}/users/set-password
Authorization: Basic <application_id>:<secret>
Content-Type: application/json
{
"email_address": "ada@example.com",
"reset_token": "<code>",
"password": "<new password>"
}username can be sent instead of email_address. Permission: users.update. Response 200 OK.
- The code must belong to that user.
- The code works once: setting the password invalidates it.
- The user is signed out on every device.
- A
UserPasswordChangedevent is recorded.
| Error | When |
|---|---|
400 ResetTokenRequired, 400 EmailAddressRequired, 400 PasswordRequired | A required value is missing |
400 PasswordMinLengthRuleError | The password is shorter than 6 characters |
401 InvalidToken | The code is invalid, expired, used, or belongs to another user |
One-time passwords#
One-time passwords (OTP) let users recover their account without email: you deliver a short code to them yourself, for example by SMS or through a support agent, and they exchange it for a password reset.
Setup#
Set otp_settings on the membership:
"otp_settings": {
"host": "https://app.example.com/reset-password",
"policy": {
"length": 6,
"contains_letters": false,
"contains_digits": true,
"expires_in": 300,
"max_attempts": 5
}
}| Field | Description |
|---|---|
host | Required. The address of the page where users enter their code; the verify request must send the same value in X-Host. |
policy.length | Number of characters of the code. |
policy.contains_letters, policy.contains_digits | The character set of the code. |
policy.expires_in | Lifetime of the code, and of the reset token it is exchanged for (counted from the verification), in seconds. 2 hours when omitted. |
policy.max_attempts | Wrong attempts allowed before the code is deleted. 5 by default, at least 1. |
1. Generate a code#
Your backend (an SMS service, a support tool) generates a code for a user:
GET /memberships/{membershipId}/users/{userId}/generate-otp
Authorization: Basic <application_id>:<secret>Permission: otp.create.{userId} (the otp resource, not users). Response 200 OK:
{
"_id": "66f1c0d2a4b5c6d7e8f90150",
"user_id": "66f1c0d2a4b5c6d7e8f90127",
"email_address": "ada@example.com",
"username": "ada",
"password": "482913",
"expires_in": 300,
"created_at": "2026-01-01T12:00:00Z",
"expire_time": "2026-01-01T12:05:00Z",
"membership_id": "66f1c0d2a4b5c6d7e8f90123"
}password is the code. Deliver it to the user. It is returned only in this response: ErtisAuth stores only a keyed hash of it. Generating a new code deletes the previous ones of the user.
No reset token exists at this point: it is generated only when the user verifies the code. So the service that generates codes can't change anyone's password itself, and neither can someone who reads the database.
| Error | When |
|---|---|
400 OtpNotConfiguredYet, 400 OtpHostNotConfiguredYet | The membership has no OTP settings |
401 UserInactive | The account is inactive or frozen |
404 UserNotFound | Unknown user |
2. Verify the code#
The user enters their username (or email address) and the code on your page, which calls:
POST /verify-otp
X-Ertis-Alias: <membership_id>
X-Host: https://app.example.com/reset-password
Content-Type: application/json
{
"username": "ada",
"password": "482913"
}No token is needed. X-Host must equal the membership's otp_settings.host. Codes are compared case-insensitively.
Response 200 OK: a reset token, generated now. Its lifetime (expires_in) starts at this moment.
{
"reset_token": "NjZmMWMwZDJhNGI1YzZkN2U4ZjkwMTIzOmV5Smhi…",
"expires_in": 300,
"created_at": "2026-01-01T12:00:00Z"
}| Error | When |
|---|---|
400 OtpHostRequired | X-Host is missing |
401 OtpHostMismatch | X-Host is not the membership's OTP host |
401 InvalidCredentials | Wrong code or unknown user |
401 OtpExpired | The code was right but has expired |
401 UserInactive | The account was deactivated or frozen after the code was generated |
A code can be used once: a successful verification deletes it, so verifying the same code again answers 401 InvalidCredentials. The reset token stays valid until it expires or is used, so there is no need to verify again.
Each wrong code counts as a failed attempt; after max_attempts failures the code is deleted and a new one must be generated.
3. Set the new password#
Call POST /users/set-password with reset_token from the previous step.
Found a mistake in the docs? Open an issue