ErtisAuth

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.

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:

FlowLink
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:

json
{
	"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#

  1. Add a mail provider to the membership (mail_providers, see Memberships).
  2. Create a mail hook named exactly User Activation, for the event UserCreated, with status active. Use {{activationLink}} in its template:
    json
    {
    	"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>"
    }
    The template receives user (the new user) and activationLink.
  3. Set user_activation to active on 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#

  1. Create the user with the X-Host header set to your activation page:
    http
    POST /memberships/{membershipId}/users
    X-Host: https://app.example.com/activate
    The user is created inactive and the activation mail is queued. If X-Host is missing, the user is still created but no mail is sent; resend it later.
  2. The user clicks the link and lands on https://app.example.com/activate?uat=<code>.
  3. Your backend activates the account:
    http
    GET /memberships/{membershipId}/users/activation?uat=<code>
    Authorization: Basic <application_id>:<secret>
    Permission: users.update. Response 200 OK with 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.

ErrorWhen
401 InvalidTokenThe code is malformed, expired, already used, or of another membership
400 UserAlreadyActiveThe user is already active

Resend the activation mail#

http
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:

json
{ "emailAddress": "ada@example.com" }
ErrorWhen
400 HostRequired, 400 EmailAddressRequiredA required value is missing
400 UserAlreadyActiveNothing to activate
404 UserNotFoundNo user with this email address
501 NotDefinedAnyMailProvider, 501 ActivationMailHookWasNotDefinedMails can't be sent

Activating without a mail#

An administrator can activate a user directly with GET /users/{id}/activate.

Password reset#

Setup#

  1. Add a mail provider to the membership.
  2. Create a mail hook named exactly Reset Password, for the event UserPasswordReset, with status active. Use {{resetPasswordLink}} in its template:
    json
    {
    	"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>"
    }
    The template receives user and resetPasswordLink.
  3. Optionally set reset_password_token_expires_in on the membership (2 hours by default).

1. Request the reset#

Your "forgot password" page's backend calls:

http
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:

json
{
	"message": "Reset token generated",
	"expiresIn": 7200
}

The mail is queued and a UserPasswordReset event is recorded.

ErrorWhen
400 HostRequired, 400 EmailAddressRequiredA required value is missing
401 UserInactiveThe account is inactive or frozen: it can't be recovered this way
404 UserNotFoundNo user with this email address
501 NotDefinedAnyMailProvider, 501 ResetPasswordMailHookWasNotDefinedMails can't be sent
Note: an unknown email address answers 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").

When the user opens https://app.example.com/reset-password?rpt=<code>, check the code before you show the form:

http
GET /memberships/{membershipId}/users/verify-reset-token?token=<code>
Authorization: Basic <application_id>:<secret>

Permission: users.read. Response 200 OK:

json
{ "email_address": "ada@example.com" }

An invalid, expired or used code answers 401 InvalidToken.

3. Set the new password#

http
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 UserPasswordChanged event is recorded.
ErrorWhen
400 ResetTokenRequired, 400 EmailAddressRequired, 400 PasswordRequiredA required value is missing
400 PasswordMinLengthRuleErrorThe password is shorter than 6 characters
401 InvalidTokenThe 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:

json
"otp_settings": {
	"host": "https://app.example.com/reset-password",
	"policy": {
		"length": 6,
		"contains_letters": false,
		"contains_digits": true,
		"expires_in": 300,
		"max_attempts": 5
	}
}
FieldDescription
hostRequired. The address of the page where users enter their code; the verify request must send the same value in X-Host.
policy.lengthNumber of characters of the code.
policy.contains_letters, policy.contains_digitsThe character set of the code.
policy.expires_inLifetime of the code, and of the reset token it is exchanged for (counted from the verification), in seconds. 2 hours when omitted.
policy.max_attemptsWrong 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:

http
GET /memberships/{membershipId}/users/{userId}/generate-otp
Authorization: Basic <application_id>:<secret>

Permission: otp.create.{userId} (the otp resource, not users). Response 200 OK:

json
{
	"_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.

ErrorWhen
400 OtpNotConfiguredYet, 400 OtpHostNotConfiguredYetThe membership has no OTP settings
401 UserInactiveThe account is inactive or frozen
404 UserNotFoundUnknown user

2. Verify the code#

The user enters their username (or email address) and the code on your page, which calls:

http
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.

json
{
	"reset_token": "NjZmMWMwZDJhNGI1YzZkN2U4ZjkwMTIzOmV5Smhi…",
	"expires_in": 300,
	"created_at": "2026-01-01T12:00:00Z"
}
Note: this is not an access token. It can only be used to set a new password.
ErrorWhen
400 OtpHostRequiredX-Host is missing
401 OtpHostMismatchX-Host is not the membership's OTP host
401 InvalidCredentialsWrong code or unknown user
401 OtpExpiredThe code was right but has expired
401 UserInactiveThe 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