Authentication
Vincent Depassier · August 30, 2026
End User Authentication
Everything under /{workspaceId}/auth — what to call, what to send, and what comes back.
https://gateway.praxsuite.com/{workspaceId}/auth/...What each endpoint needs
Read this table before anything else. Most integration problems here are a wrong credential, not a wrong body.
Endpoint | Needs |
| nothing — fully public |
| nothing — fully public, opened from an email link |
| an API key ( |
| an API key |
| an API key |
| an API key |
| an API key |
| an API key |
| an API key |
| an API key |
| the end user's own JWT |
| an API key |
| an API key |
The API key requirement is deliberate: it means only a caller holding a legitimate key for the workspace can create or authenticate accounts in it. A pk_live_ key is enough — these endpoints are designed to be called from a browser.
change-password is the exception in the other direction: it changes the password of whoever is holding the token, so it takes the token rather than a key.
Bootstrapping a browser client
A frontend needs a publishable key before it can call anything. GET /auth/config hands it over, with no authentication at all:
GET https://gateway.praxsuite.com/{workspaceId}/auth/configIt returns the workspace's pk_live_ key. That is safe by design — a publishable key is analogous to a Stripe publishable key, and its power comes from its scopes, not from secrecy. It saves you baking the key into a build, which in turn means rotating it does not mean redeploying.
Register
POST /{workspaceId}/auth/register
Authorization: Bearer pk_live_xxxxxxxx
Content-Type: application/json
{
"email": "ana.rojas@example.com",
"password": "…",
"firstName": "Ana",
"lastName": "Rojas"
}Creates a local account and sends a confirmation email. The account can sign in before confirming — emailVerified reports the state and your app decides what that gates.
Login
POST /{workspaceId}/auth/login
Authorization: Bearer pk_live_xxxxxxxx
Content-Type: application/json
{ "email": "ana.rojas@example.com", "password": "…" }{
"isSuccess": true,
"data": {
"accessToken": "eyJhbGciOi...",
"refreshToken": "…",
"accessTokenExpiresAt": "2026-08-30T18:40:00Z",
"refreshTokenExpiresAt": "2026-09-29T18:10:00Z",
"tokenType": "Bearer",
"user": { "id": "…", "email": "ana.rojas@example.com" }
}
}If the workspace has an OIDC provider configured, login goes through it transparently — the gateway validates the credentials against the provider and returns its own token. No browser redirect, no change to your client. If there is no provider, it falls through to local authentication.
What is inside the access token
The JWT is a normal signed token, and its claims are what row filters and the Event Bus read:
Claim | Value |
| The end user's id — this is the one row filters use |
| Their email |
| The workspace id |
| Always |
| One entry per role name they hold |
| One entry per role id they hold |
| Present when the profile has them |
| Token id and issued-at |
Anything in this list can be referenced from a role's row filter with valueFromClaim, or from a column default with {{claim:…}}. sub is the one you will use ninety percent of the time.
Refresh, and why rotation matters
POST /{workspaceId}/auth/refresh
Authorization: Bearer pk_live_xxxxxxxx
{ "refreshToken": "…" }You get a new access token and a new refresh token. The one you sent is invalidated in the same operation.
That has a practical consequence: store only the newest refresh token. If two tabs refresh concurrently, one of them wins and the other is holding a token that no longer works. Serialise refreshes in a single place in your client, or accept that a losing tab has to sign in again.
The refresh token is 64 random bytes and is stored only as a hash, alongside the IP and user agent that created it — which is what makes the session list in the portal meaningful.
Logout
POST /{workspaceId}/auth/logout
{ "refreshToken": "…" }Revokes that one refresh token — one device, one session. It does not touch the user's other sessions, and it does not invalidate an access token that is already issued: that one dies when it expires.
To end every session at once, deactivate the account (or use the admin password reset, which also revokes sessions).
Password reset, in three steps
The flow is deliberately three calls rather than one link, because an OTP that arrives by email should not be usable for long.
forgot-password ──▶ 6-digit code by email (valid 15 minutes)
│
▼
verify-reset-code ──▶ short-lived session token (valid 10 minutes)
│
▼
reset-password ──▶ password changedPOST /auth/forgot-password { "email": "…" }
POST /auth/verify-reset-code { "email": "…", "code": "123456" }
POST /auth/reset-password { "sessionToken": "…", "newPassword": "…" }`forgot-password` always returns 200, whether or not the address exists. So does resend-confirmation. That is not a bug to work around — it stops the endpoint being used to discover which addresses are registered. Your UI should say "if that address exists, we sent a code" and mean it.
An administrator can instead email a reset link from the End Users tab, which is valid for 60 minutes and lets the person set their own password. Prefer that to setting a password on their behalf.
Changing a password while signed in
POST /{workspaceId}/auth/change-password
Authorization: Bearer <the end user's JWT>
{ "currentPassword": "…", "newPassword": "…" }This is the only auth endpoint that takes the end user's token instead of an API key.
Signing in with an external provider
A workspace can delegate authentication to any OIDC provider — a corporate directory, an identity server, a social login. Each one is registered with a slug, a display name, a discovery URL, a client id and a secret (encrypted at rest, and never returned by the API).
GET /auth/oidc/{providerSlug} ──▶ { authorizationUrl }
│
│ redirect the browser there; the provider sends it back with ?code&state
▼
POST /auth/oidc/callback { code, state } ──▶ the same token pair as a local loginThe end result is identical to a local login: a gateway JWT with the same claims. Which provider a person used is recorded on the account as authProvider and providerSubject.
Admin operations
These live on /{workspaceId}/endusers and take a portal session, not an API key — they are the End Users tab's own API.
Operation | Effect |
Create / update / list | The obvious things |
Deactivate | Flips |
Delete (permanent) | Irreversible, takes related data with it |
Send password reset | Emails a 60-minute link so the person sets it themselves |
Set password | Sets it directly and revokes their active sessions |
Resend confirmation | Invalidates outstanding tokens and sends a fresh one |
Assign / remove roles | The only thing that changes what they can see |
Import | Validate a file first, then execute |
Wiring it up
const GATEWAY = "https://gateway.praxsuite.com";
const WORKSPACE = "3fa85f64-5717-4562-b3fc-2c963f66afa6";
// 1. Fetch the publishable key once, at startup. No auth needed.
const { publishableKey } = await fetch(`${GATEWAY}/${WORKSPACE}/auth/config`)
.then((r) => r.json());
// 2. Sign the user in with it.
async function login(email, password) {
const res = await fetch(`${GATEWAY}/${WORKSPACE}/auth/login`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${publishableKey}`,
},
body: JSON.stringify({ email, password }),
});
const { data } = await res.json();
// Keep only the newest refresh token — refreshing rotates it.
localStorage.setItem("refresh", data.refreshToken);
return data.accessToken;
}
// 3. From here on, every call carries the user's token, not the key.
async function myOrders(accessToken) {
return fetch(`${GATEWAY}/${WORKSPACE}/query`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${accessToken}`,
},
// No "where mine" clause — the role's row filter adds it server-side.
body: JSON.stringify({
refs: { Orders: "…" },
query: { from: "Orders", limit: 25 },
}),
}).then((r) => r.json());
}The comment on the last call is the whole point of the system: the client does not ask for its own rows, and could not ask for anybody else's.
Next
Gateway roles — where that row filter comes from.
Connecting an app to the Event Bus — the same token, used for realtime.