Authentication
Tokens, sign-in, refresh, verification and sign-out.
ErtisAuth authenticates two kinds of callers:
- Users sign in with a username or email address and a password (or through an external provider, or with the device code flow) and receive a pair of Bearer tokens: an access token and a refresh token.
- Applications send a Basic token made of their id and secret with every request. There is nothing to sign in to.
Both are sent in the Authorization header:
Authorization: Bearer eyJhbGciOiJIUzI1NiIs…
Authorization: Basic 66f1c0d2a4b5c6d7e8f90126:<application_secret>Endpoints#
| Method | Route | Description | Auth |
|---|---|---|---|
POST | /generate-token | Sign in, or get a scoped token | none / Bearer |
GET POST | /refresh-token | Get a new token pair with a refresh token | refresh token |
GET POST | /verify-token | Check a token | any token |
GET POST | /revoke-token | Sign out | access or refresh token |
GET | /me, /whoami | Get the owner of a token | Bearer / Basic |
POST | /oauth/{slug}/login | Sign in with an external provider | none |
POST | /verify-otp | Exchange a one-time password for a reset token | none |
The last two are described in External Identity Providers and Account Recovery.
The tokens#
Access token#
A JWT signed with HMAC-SHA256 using the membership's secret_key. Its claims:
| Claim | Value |
|---|---|
sub | The user id |
prn | The membership id |
jti | A unique token id |
iss | The membership name |
aud | The membership slug |
given_name, family_name | The user's first and last name |
unique_name | The username |
email | The email address |
scope | Space-separated scopes (scoped tokens only) |
iat, nbf, exp | Issue, not-before and expiry times |
Its lifetime is the membership's expires_in (in seconds).
You may decode the token to read these claims, but don't treat a token as valid just because its signature is valid: a token can be revoked before it expires, and the user can be deactivated. Call /verify-token or /me, or use the SDK, which does it for you.
Refresh token#
A JWT like the access token, with an additional refresh_token: true claim, valid for the membership's refresh_token_expires_in. It can only be used to get a new token pair: using it as an access token is rejected with 401 InvalidToken.
Basic token#
<application_id>:<secret>. It doesn't expire; it stops working when the secret is rotated or the application is deleted. Any problem (unknown application, wrong secret, unknown membership) is reported the same way, as 401 InvalidToken, so that application ids can't be probed.
Sign in#
POST /generate-token| Header | Required | Description |
|---|---|---|
X-Ertis-Alias | yes | The membership id (Membership or MembershipId also work) |
X-IpAddress | no | The end user's IP address, stored with the session |
X-UserAgent | no | The end user's user agent, stored with the session |
{
"username": "ada@example.com",
"password": "<password>"
}username accepts either the username or the email address.
Response 201 Created
{
"token_type": "Bearer",
"access_token": "eyJhbGciOiJIUzI1NiIs…",
"expires_in": 3600,
"refresh_token": "eyJhbGciOiJIUzI1NiIs…",
"refresh_token_expires_in": 86400,
"created_at": "2026-01-01T12:00:00Z"
}expires_in and refresh_token_expires_in are in seconds, counted from created_at.
Errors
| Status | Code | When |
|---|---|---|
400 | MembershipIdRequired | X-Ertis-Alias is missing |
401 | InvalidCredentials | Unknown user or wrong password |
401 | UserInactive | The password is right but the account is not active (not activated yet, or frozen) |
404 | MembershipNotFound | The membership does not exist |
To protect your users:
- an unknown user and a wrong password give the same answer, and take about the same time, so that attackers can't find out which accounts exist;
- the account status (
UserInactive) is only revealed to callers who know the correct password.
A successful sign-in records a TokenGenerated event and an active token.
Signing in from your backend#
When your backend signs users in on their behalf (a server-rendered web app, a BFF), pass the end user's details so that sessions show where they come from:
curl -X POST https://auth.example.com/generate-token \
-H 'X-Ertis-Alias: <membership_id>' \
-H 'X-IpAddress: 203.0.113.42' \
-H 'X-UserAgent: Mozilla/5.0 (Macintosh; Intel Mac OS X 14_0) …' \
-H 'Content-Type: application/json' \
-d '{ "username": "ada", "password": "<password>" }'Scoped tokens#
A scoped token is an access token that can do only part of what its user can do. Use one when you hand a token to a less trusted party: a browser extension, a third-party integration, a short-lived job.
Request it with a valid access token and the list of scopes, without credentials:
curl -X POST https://auth.example.com/generate-token \
-H 'X-Ertis-Alias: <membership_id>' \
-H 'Authorization: Bearer <access_token>' \
-H 'Content-Type: application/json' \
-d '{ "scopes": [ "users.read", "roles.read" ] }'Response 201 Created
{
"token_type": "Bearer",
"access_token": "eyJhbGciOiJIUzI1NiIs…",
"expires_in": 43200,
"created_at": "2026-01-01T12:00:00Z",
"scopes": [ "users.read", "roles.read" ]
}Rules:
- Scopes use the permission format (
users.read,*.users.read.*…). - The user must have every requested permission; otherwise the request fails with
400 UserHasNoPermissionForThisScope. An invalid expression fails with400 InvalidScope, an empty list with400 ScopeRequired. - A scoped token can only be narrowed: a scoped token can request a new one with fewer scopes, never with more.
- A request made with a scoped token must be allowed both by the user's permissions and by the token's scopes. The scopes are the final gate: they never grant anything the user doesn't have.
- The lifetime is the membership's
scoped_token_expires_in, or 12 hours when it is not set. - No refresh token is returned.
Refresh a token#
Exchange a refresh token for a new token pair before the access token expires.
GET /refresh-token
Authorization: Bearer <refresh_token>or, with the refresh token in the body:
POST /refresh-token
Content-Type: application/json
{ "token": "<refresh_token>" }| Query parameter | Default | Description |
|---|---|---|
revoke | true | Revokes the refresh token that was used, so that it works only once. Send revoke=false to keep it usable. |
Response 201 Created: a new token pair, in the same shape as the sign-in response. A refreshed token keeps the scopes of the original one.
Errors
| Status | Code | When |
|---|---|---|
400 | RefreshTokenRequired | No token in the header or the body |
401 | TokenIsNotRefreshable | The token is an access token, not a refresh token |
401 | RefreshTokenWasExpired | The refresh token has expired: the user must sign in again |
401 | RefreshTokenWasRevoked | The refresh token was already used or revoked |
401 | UserInactive | The user has been deactivated since |
Verify a token#
Checks a Bearer or Basic token: signature, expiry, revocation, and that its user or application still exists and is active.
GET /verify-token
Authorization: Bearer <token>or
POST /verify-token
Content-Type: application/json
{ "token": "Bearer <token>" }In the body, the token is given with its type (Bearer … or Basic …).
Response 200 OK for a Bearer token:
{
"verified": true,
"token": "eyJhbGciOiJIUzI1NiIs…",
"token_kind": "access_token",
"remaining_time": 2875
}| Field | Description |
|---|---|
verified | Whether the token is valid |
token_kind | access_token or refresh_token |
remaining_time | Seconds until the token expires |
For a Basic token, the response has verified and token only.
An invalid token answers 401 with the reason:
| Code | When |
|---|---|
InvalidToken | Malformed, wrong signature, unknown membership, or a reset/activation token |
TokenWasExpired | Expired |
TokenWasRevoked | Revoked (signed out, password changed, user frozen…) |
UserInactive | The user has been deactivated |
Get the token owner#
GET /me
Authorization: Bearer <access_token>/whoami is the same endpoint under another name.
- With a Bearer token the response is the full user, including the custom fields of their user type (never the password hash).
- With a Basic token the response is the application (never its secret).
{
"_id": "66f1c0d2a4b5c6d7e8f90124",
"username": "ada",
"firstname": "Ada",
"lastname": "Lovelace",
"email_address": "ada@example.com",
"role": "admin",
"user_type": "employee",
"permissions": [],
"forbidden": [],
"is_active": true,
"source_provider": "ErtisAuth",
"membership_id": "66f1c0d2a4b5c6d7e8f90123",
"sys": { "created_at": "2026-01-01T12:00:00Z", "created_by": "system" }
}Sign out#
Revokes a token. A token pair is revoked together: revoking the access token also revokes its refresh token, and the other way round.
GET /revoke-token
Authorization: Bearer <access_token>or
POST /revoke-token
Content-Type: application/json
{ "token": "<access_token>" }| Query parameter | Default | Description |
|---|---|---|
logout-all | false | true revokes all the tokens of the user, on every device |
Response 204 No Content when the token was revoked, 401 when it could not be (already revoked, expired, invalid).
Revoked tokens are listed in Sessions and record a TokenRevoked event.
Other ways tokens get revoked#
| Action | Effect |
|---|---|
| Change password | All the user's tokens are revoked, except the session in which users changed their own password |
| Set a new password with a reset token | All the user's tokens are revoked |
| Freeze a user | All the user's tokens are revoked and the user can't sign in |
| Rotate an application secret | The old Basic token stops working immediately |
Recommended client flow#
- Sign in with
/generate-tokenand keep both tokens. - Send the access token with each request.
- Shortly before
expires_inruns out, or when a request answers401 TokenWasExpired, call/refresh-tokenand replace both tokens. - When the refresh fails with
401, send the user to the sign-in page. - On sign-out, call
/revoke-token(withlogout-all=truefor "sign out everywhere").
Found a mistake in the docs? Open an issue