ErtisAuth

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:

http
Authorization: Bearer eyJhbGciOiJIUzI1NiIs…
Authorization: Basic 66f1c0d2a4b5c6d7e8f90126:<application_secret>
Note: unlike HTTP Basic authentication, the Basic token is not base64-encoded: it is the application id and the secret separated by a colon, as plain text. Always use HTTPS.

Endpoints#

MethodRouteDescriptionAuth
POST/generate-tokenSign in, or get a scoped tokennone / Bearer
GET POST/refresh-tokenGet a new token pair with a refresh tokenrefresh token
GET POST/verify-tokenCheck a tokenany token
GET POST/revoke-tokenSign outaccess or refresh token
GET/me, /whoamiGet the owner of a tokenBearer / Basic
POST/oauth/{slug}/loginSign in with an external providernone
POST/verify-otpExchange a one-time password for a reset tokennone

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:

ClaimValue
subThe user id
prnThe membership id
jtiA unique token id
issThe membership name
audThe membership slug
given_name, family_nameThe user's first and last name
unique_nameThe username
emailThe email address
scopeSpace-separated scopes (scoped tokens only)
iat, nbf, expIssue, 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#

http
POST /generate-token
HeaderRequiredDescription
X-Ertis-AliasyesThe membership id (Membership or MembershipId also work)
X-IpAddressnoThe end user's IP address, stored with the session
X-UserAgentnoThe end user's user agent, stored with the session
json
{
	"username": "ada@example.com",
	"password": "<password>"
}

username accepts either the username or the email address.

Response 201 Created

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

StatusCodeWhen
400MembershipIdRequiredX-Ertis-Alias is missing
401InvalidCredentialsUnknown user or wrong password
401UserInactiveThe password is right but the account is not active (not activated yet, or frozen)
404MembershipNotFoundThe 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:

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

shell
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

json
{
	"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 with 400 InvalidScope, an empty list with 400 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.

http
GET /refresh-token
Authorization: Bearer <refresh_token>

or, with the refresh token in the body:

http
POST /refresh-token
Content-Type: application/json

{ "token": "<refresh_token>" }
Query parameterDefaultDescription
revoketrueRevokes 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

StatusCodeWhen
400RefreshTokenRequiredNo token in the header or the body
401TokenIsNotRefreshableThe token is an access token, not a refresh token
401RefreshTokenWasExpiredThe refresh token has expired: the user must sign in again
401RefreshTokenWasRevokedThe refresh token was already used or revoked
401UserInactiveThe user has been deactivated since
Note: refreshing does not revoke the previous access token; it stays valid until it expires. Revoke it explicitly if you need it to stop working immediately.

Verify a token#

Checks a Bearer or Basic token: signature, expiry, revocation, and that its user or application still exists and is active.

http
GET /verify-token
Authorization: Bearer <token>

or

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

json
{
	"verified": true,
	"token": "eyJhbGciOiJIUzI1NiIs…",
	"token_kind": "access_token",
	"remaining_time": 2875
}
FieldDescription
verifiedWhether the token is valid
token_kindaccess_token or refresh_token
remaining_timeSeconds until the token expires

For a Basic token, the response has verified and token only.

An invalid token answers 401 with the reason:

CodeWhen
InvalidTokenMalformed, wrong signature, unknown membership, or a reset/activation token
TokenWasExpiredExpired
TokenWasRevokedRevoked (signed out, password changed, user frozen…)
UserInactiveThe user has been deactivated

Get the token owner#

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

http
GET /revoke-token
Authorization: Bearer <access_token>

or

http
POST /revoke-token
Content-Type: application/json

{ "token": "<access_token>" }
Query parameterDefaultDescription
logout-allfalsetrue 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#

ActionEffect
Change passwordAll the user's tokens are revoked, except the session in which users changed their own password
Set a new password with a reset tokenAll the user's tokens are revoked
Freeze a userAll the user's tokens are revoked and the user can't sign in
Rotate an application secretThe old Basic token stops working immediately
  1. Sign in with /generate-token and keep both tokens.
  2. Send the access token with each request.
  3. Shortly before expires_in runs out, or when a request answers 401 TokenWasExpired, call /refresh-token and replace both tokens.
  4. When the refresh fails with 401, send the user to the sign-in page.
  5. On sign-out, call /revoke-token (with logout-all=true for "sign out everywhere").

Found a mistake in the docs? Open an issue