Praxsuite

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

GET /auth/config

nothing — fully public

GET /auth/confirm-email

nothing — fully public, opened from an email link

POST /auth/register

an API key (pk_live_ or sk_live_)

POST /auth/login

an API key

POST /auth/refresh

an API key

POST /auth/logout

an API key

POST /auth/forgot-password

an API key

POST /auth/verify-reset-code

an API key

POST /auth/reset-password

an API key

POST /auth/resend-confirmation

an API key

POST /auth/change-password

the end user's own JWT

GET /auth/oidc/{providerSlug}

an API key

POST /auth/oidc/callback

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/config

It 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

sub

The end user's id — this is the one row filters use

email

Their email

workspace

The workspace id

auth_type

Always gateway_enduser

role

One entry per role name they hold

role_id

One entry per role id they hold

given_name / family_name

Present when the profile has them

jti, iat

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 changed
POST /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 login

The 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 isActive and revokes every session

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