Documentation

Authentication
without the pain.

Everything you need to plug Authyon into your app: sign your users in, keep them signed in, protect your API, and let people manage their own team pages. Every section starts with a plain-language explanation and ends with a copy-pasteable example.

POST /auth/login
// 1. Your app logs the user in
const res = await fetch("https://api.authyon.com/auth/login", {
  method: "POST",
  headers: {
    "X-Authyon-Env": "pk_live_...",
    "Content-Type": "application/json"
  },
  body: JSON.stringify({
    email: "alice@acme.com",
    password: "...",
    tenantSlug: "acme"
  })
});

// 2. Store tokens, send bearer to your API
const { accessToken, refreshToken } = await res.json();

Which section is for me?

Pick the role that matches what you're building. Each card jumps to the exact section you need.

🌐 I build the frontend

Web app, mobile app, or SPA that lets people sign in and use your product.

Read: Core conceptsAuth flowsLoginRefresh.

🔧 I build the backend

Your API needs to check who the caller is and (maybe) manage users on their behalf.

Read: Verify tokensServer-to-serverUser managementWebhooks.

👥 I manage a team / organization

A regular user who owns or administers a workspace (a "tenant") inside someone else's product.

Read: Self-service tenant — how you invite people, rename the org, etc. with just your login.

Core concepts

Three words show up all over these docs. Here's a plain-English translation before we go anywhere else:

1 Workspace

Your Authyon account. Think of it as the "company folder" that holds everything you build — like your GitHub org, but for identity. You manage it in the dashboard, not via the API.

2 Environment

A copy of your product's identity setup. Most people have two: test (for development) and live (for real users). They're isolated from each other — testing in test can't leak anything into live. All login / signup URLs live under an environment.

3 Tenant

An organization inside an environment. If your product is used by different companies (Acme Inc. and Globex Corp.), each one is a "tenant". A person can be a member of several tenants and switch between them without logging out.

The three types of "key"

Authyon uses three kinds of credentials, each with a specific purpose. You'll only touch one or two of them depending on what you build.

TypeWhere you use itWho needs it
Publishable key
pk_test_… / pk_live_…
Header X-Authyon-Env on /auth/* calls. Frontend / mobile. Tells Authyon which environment you're talking to. Safe to ship in your public JavaScript — it's just an identifier, not a password.
Environment client secret
client_id + client_secret
Traded for an admin token via /env/oauth/token. Your backend. Full power over the environment — create users, invite people to any tenant, rotate keys. Never put in the frontend.
Tenant client secret Traded via /tenant/oauth/token. Same idea as above but limited to one tenant. Useful when a specific customer wants their own integration and shouldn't see other customers' data.

Create + rotate these in the dashboard: Environment → API keys. Rotating a secret only breaks the one you rotated — nothing else stops working.

How the pieces fit together

Three flows cover 95% of what you'll do with Authyon. Read them once and the rest of the docs is just detail.

1. Someone signs into your app

Your frontend calls POST /auth/login with the user's email + password. You get back two tokens:

  • An access token (a signed string, ~30 min lifetime). Send it as Authorization: Bearer … to every request your frontend makes to your API.
  • A refresh token (~14 days lifetime). Keep it safe; use it to get a new access token when the old one expires.
# 1. Sign in
curl -X POST https://api.authyon.com/auth/login \
  -H "X-Authyon-Env: pk_live_..." \
  -H "Content-Type: application/json" \
  -d '{"email":"alice@acme.com","password":"...","tenantSlug":"acme"}'

# 2. Later — call your own API with the access token
curl https://your-api.example.com/orders \
  -H "Authorization: Bearer eyJhbGciOi..."

# 3. When the access token expires (~30 min), swap it for a new one
curl -X POST https://api.authyon.com/auth/refresh \
  -H "X-Authyon-Env: pk_live_..." \
  -d '{"refreshToken":"rt_..."}'

2. Your backend needs to manage users / tenants

Say you want to script "create 50 accounts" or hook a webhook receiver that disables users when payment fails. Your backend swaps its client_id/client_secret for an admin token, then hits the /env/* endpoints:

# 1. Get an admin token (valid ~1 hour)
curl -X POST https://api.authyon.com/env/oauth/token \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "grant_type=client_credentials&client_id=env_...&client_secret=..."

# 2. Use it to manage the environment
curl https://api.authyon.com/env/users \
  -H "Authorization: Bearer eyJ..."

3. A tenant admin manages their own team

Regular people managing their own workspace (rename the org, invite a coworker, remove someone) don't need any backend credential. They use the token they got from step 1 — the same one your frontend already has — and call the self-service tenant endpoints.

This is the one most people miss. If your product has a "team settings" page, point it at /auth/tenants/{id}/* and skip the backend entirely.

How errors look

Every error comes back in the same shape. You can parse them reliably:

{
  "title": "Unauthorized",
  "status": 401,
  "detail": "This token is no longer valid.",
  "code": "token.stale"
}

Match on the code field — it's stable and machine-readable. title is a friendly label that may change wording; don't parse it.

For the frontend

Endpoints your app calls directly

These are the endpoints your frontend (or mobile app) talks to when a user signs up, signs in, resets a password, and so on. They're the public surface — safe to call from a browser or app because they only accept the publishable key (X-Authyon-Env), which isn't secret.

Endpoints marked Bearer JWT also need the access token from /auth/login in the Authorization header.

POST /auth/register Publishable key

Creates a new user in the environment. Wire this to your sign-up form. We reject weak passwords and passwords that already leaked in known breaches — the client sees a clear error code, not a generic 400. Capped at 20 sign-ups per hour from the same IP.

Request body
{
  "email": "alice@acme.com",
  "username": "alice",   // optional under the AutoFromEmail policy — see below
  "password": "S3cure!Pass"
}
Response — 201 Created
{ "id": "01H..." }
Errors
  • user.email_taken — 409
  • user.username.taken — 409 (only under the Reject policy)
  • user.username.empty — 400 (username omitted under a non-Auto policy)
  • user.password_weak — 400
  • user.password_pwned — 400 (breach-check validation)
Username policy (Environment Settings → Sign-in)

The environment's UsernameCollisionPolicy controls what happens when the requested username is missing or already taken:

  • reject — Default. Username required. Return 409 on conflict.
  • suffix — Username required. Conflict → append smallest free integer (alicealice2).
  • random — Username required. Conflict → append short base36 tag (alice.k4a1).
  • autoFromEmail — Username optional. When omitted, Authyon derives one from the email local-part (alice+dev@x.comalice.dev) and applies the suffix ladder on conflict.
POST /auth/login Publishable key

Sign a user in. You get back two tokens — an access token (short-lived, send it in Authorization: Bearer …) and a refresh token (long-lived, exchange it later via /auth/refresh). If the account has two-factor turned on, you get a challenge instead — check twoFactorRequired in the response and prompt the user for their code.

Request body
{
  "email": "alice@acme.com",        // or "username"
  "password": "S3cure!Pass",
  "tenantSlug": "acme"              // optional — picks the active tenant on the token
}
Response — 200 (no 2FA)
{
  "accessToken": "eyJhbGciOi...",
  "refreshToken": "rt_...",
  "expiresIn": 1800,
  "user": { "id": "01H...", "email": "..." }
}
Response — 200 (2FA required)
{
  "twoFactorRequired": true,
  "challengeId": "chg_...",
  "methods": ["authenticator", "email"],
  "emailHint": "a****@a***.com"
}

Then call /auth/2fa/challenge with the code.

Rate limits
  • 10 req / 5min / IP
  • 5 req / 5min / email — protects against credential stuffing
POST /auth/refresh Publishable key

Trade an expiring session for a fresh one. Access tokens live ~30 minutes; when yours is about to expire, POST the refresh token here and you get a new pair back. Important: each refresh token works exactly once — if the same one shows up twice, we treat it as a stolen token and revoke everything the user has signed in on.

Request body
{ "refreshToken": "rt_..." }
Response — 200
{
  "accessToken": "eyJ...",
  "refreshToken": "rt_...",
  "expiresIn": 1800
}
POST /auth/logout Publishable key

Revoke a refresh token. Body optional — with a bearer JWT and no body, revokes every refresh token for the user (logout-everywhere).

Request body (optional)
{ "refreshToken": "rt_..." }
Response — 204 No Content
GET /auth/me Bearer JWT

Return the fresh profile of the current user — pulled from the in-memory snapshot, so role/permission changes propagate instantly.

Response — 200
{
  "id": "01H...",
  "email": "alice@acme.com",
  "tenants": [{ "id": "...", "slug": "acme", "roles": ["admin"] }],
  "activeTenant": "acme",
  "permissions": ["orders:read", "orders:write"]
}
GET /auth/tenants Bearer JWT

List every tenant the user belongs to — used to render a tenant switcher. Returns { id, slug, name }.

Self-service /auth/tenants/{tenantId}/* Bearer JWT

The "team settings" page you point your users at. If someone is a member of a tenant, they can read its info and — if they hold the right permission — manage it (rename, invite, remove) using just their own login. No backend secret required. Every request re-checks membership + permissions against a fresh copy of the user.

Create a new tenant (self-service)

Any signed-in user can create a tenant when the environment allows it. The moment the tenant is created, the caller becomes its first member and automatically gets the three permissions needed to manage it (tenants:manage, tenants:members:invite, tenants:members:remove). So there's no "chicken and egg" problem — you can rename or invite people right after creating.

curl -X POST https://api.authyon.com/auth/tenants \
  -H "X-Authyon-Env: pk_live_..." \
  -H "Authorization: Bearer eyJ..." \
  -H "Content-Type: application/json" \
  -d '{"name":"Acme Inc","slug":"acme"}'

Enable it per environment: Environment Settings → Sign-in → Self-service tenant creation. When off, this endpoint returns environment.self_service_disabled (403). Rate-limited to 5 creations per user / hour and 20 per IP / hour.

Unblocking a user who hit the cap. A user tripped by the 5/hour ceiling gets rate_limit.exceeded until the Redis bucket ages out (max 60 min). Waiting for the TTL is fine for most cases — but if you need to release someone right now, open Users → <user> → Account and press Clear rate limits. It calls POST /platform/workspaces/{ws}/environments/{env}/users/{id}/reset-rate-limits, deletes every user-scoped bucket keyed on the caller's sub, and audits a UserRateLimitsCleared event. Lockout, disable and suspend flags are untouched — this only clears the throttle.

Read your tenant + members

Any member can hit these — no permission needed.

GET /auth/tenants/{id} — Basic info + member count + your roles inside the tenant (use these to decide which admin buttons to render).
GET /auth/tenants/{id}/members?skip=&take= — Paginated list of members. Sensitive stuff (2FA state, tokens) is stripped.
GET /auth/tenants/{id}/roles — Custom roles defined for this tenant. Useful to render a role picker.

Manage the tenant (permission-gated)

These need the caller to hold a specific permission on the tenant. The person who created the tenant already has them; anyone else needs a role that grants the matching permission.

PATCH /auth/tenants/{id} — Rename. Needs tenants:manage. Slug can't be changed (it's baked into every issued token).
POST /auth/tenants/{id}/members — Add an existing account to the tenant. Needs tenants:members:invite. Body: {"email":"...","roles":["..."]}. Person must already have registered — call /auth/register first if they haven't.
DELETE /auth/tenants/{id}/members/{userId} — Remove someone. Needs tenants:members:remove. Can't remove yourself here (that's a separate "leave tenant" flow you build on your profile page).

Errors to expect

  • environment.self_service_disabled — 403. Env admin hasn't enabled self-service creation.
  • tenant.not_found — 404.
  • tenant.not_member — 401. The caller isn't a member (same shape whether the tenant exists or not, so no one can enumerate).
  • tenant.permission_denied — 403. Member, but missing the permission for the mutation.
  • tenant.member.email_not_found — 404 on invite. Send them a /auth/register link first.
  • tenant.member.already_exists — 409 on invite.
  • tenant.member.cannot_remove_self — 400 on self-remove.

Wiring the permissions: the mutation endpoints check custom permissions inside the tenant scope — they're not built-in. Create them in the platform UI (Environment → Permissions) and assign to a tenant role (e.g. tenant.admin) that the tenant owner holds. Members without the role can still read every non-mutation endpoint.

Error codes

  • environment.header.missing — 401 (X-Authyon-Environment absent)
  • tenant.not_found — 404
  • tenant.not_member — 401 (caller isn't a member — same shape whether the tenant exists or not, to avoid enumeration)
  • tenant.permission_denied — 403 (member but lacks the mutation permission)
  • tenant.member.email_not_found — 404 (invite target isn't a user in this env yet — send them to /auth/register first)
  • tenant.member.already_exists — 409
  • tenant.member.cannot_remove_self — 400
POST /auth/switch-tenant Bearer JWT

Change the active tenant on the current session and issue a fresh access token scoped to the new tenant.

Request body
{ "tenantSlug": "other-org" }
GET /auth/sessions Bearer JWT

List active refresh-token sessions (device, IP, last active). Use in a profile page for "sign out of other devices".

Revoke one
DELETE /auth/sessions/{sessionId}
POST /auth/password-reset/request Publishable key

Start the forgot-password flow. Always returns 204, even when the email doesn't exist — avoids leaking account existence.

Request body
{ "email": "alice@acme.com" }
POST /auth/password-reset/confirm Publishable key

Redeem the token from the email link and set a new password. Revokes every existing refresh token — post-reset the user re-signs in everywhere.

Request body
{
  "token": "prt_...",
  "newPassword": "NewS3cure!"
}
Two-factor authentication

The "second step" after a password

2FA (two-factor authentication) adds a second check on top of the password — the kind of "enter this 6-digit code from your phone" step you've seen everywhere. Authyon supports three kinds:

  • Authenticator app — Google Authenticator, 1Password, Authy, etc. The rotating 6-digit code.
  • Email code — we email a code to the user's inbox for them to type back.
  • Passkeys (WebAuthn) — fingerprint / Face ID / hardware key. Handled by a separate endpoint set.

The flow is: user turns on 2FA (enrollment), and from then on /auth/login returns a challenge instead of tokens. Your UI shows the code prompt, submits the code back, and only then you get the real tokens.

GET /auth/2fa/status Bearer JWT

Which methods (if any) the user has enrolled + remaining recovery codes.

POST /auth/2fa/authenticator/setup Bearer JWT

Start TOTP enrolment. Returns an inline SVG QR code and the base32 secret — display both to the user.

Response — 200
{
  "secret": "JBSWY3DPEHPK3PXP",
  "qrSvg": "<svg xmlns=...",
  "otpauthUri": "otpauth://totp/Authyon:..."
}
POST /auth/2fa/authenticator/confirm Bearer JWT

Finish enrolment by presenting the first 6-digit code. Response includes single-use recovery codes — show them once, they can never be recovered again.

Request body
{ "code": "123456" }
Response — 200
{
  "recoveryCodes": ["a1b2-c3d4-...", ...]  // 10 codes, single-use
}
POST /auth/2fa/challenge Publishable key

Complete a login that came back with twoFactorRequired: true.

Request body
{
  "challengeId": "chg_...",
  "code": "123456",      // or "recoveryCode": "..."
  "method": "authenticator"  // or "email"
}
Response — 200: same tokens envelope as /auth/login.
POST /auth/2fa/recovery-codes/regenerate Bearer JWT + step-up

Replace the entire recovery code set. Requires a step-up (re-entered password or fresh 2FA) via X-Authyon-StepUp cookie.

For the backend

Manage users & tenants from your own server

Use these when your backend needs to do admin things on behalf of your product — batch-creating users, listening to lifecycle webhooks, banning someone who abused your service. Every call needs an admin token you get by swapping your client_id/client_secret at /env/oauth/token. Keep those secrets on your server — never in a browser or mobile app.

If your integration only needs access to a single tenant (say, a customer with their own Zapier connection), use /tenant/oauth/token instead.

POST /env/oauth/token OAuth client_credentials

Trade your backend's client_id/client_secret for a short-lived admin token (valid ~1 hour). Send that token as Authorization: Bearer … on every /env/* call.

Request (form-encoded)
grant_type=client_credentials
client_id=env_...
client_secret=...
Response — 200
{
  "access_token": "eyJ...",
  "token_type": "Bearer",
  "expires_in": 3600,
  "scope": "env:admin"
}
CRUD /env/users env-client JWT

Full user management: create, list, read, assign role, grant permission, unlock, assign tenant.

GET /env/users — list, paginated
POST /env/users — create (skips email verification)
GET /env/users/{id} — full profile
POST /env/users/{id}/roles{"role":"admin"}
DELETE /env/users/{id}/roles/{role}
POST /env/users/{id}/permissions{"permission":"orders:write"}
DELETE /env/users/{id}/permissions/{permission}
POST /env/users/{id}/unlock — clear lockout
POST /env/users/{id}/tenants{"tenantId":"..."}
DELETE /env/users/{id}/tenants/{tenantId}
Lifecycle /env/users/{id}/{action} env-client JWT

Admin lifecycle for end-user accounts. Every action rotates the user's security stamp and revokes all live refresh tokens, so any device already signed in gets kicked on the next request.

POST /env/users/{id}/disable{"reason":"..."}. Permanent block; login refuses with user.disabled. Reverse via enable.
POST /env/users/{id}/enable — clears IsDisabled. User must sign in again.
POST /env/users/{id}/suspend{"durationMinutes":1440,"reason":"..."}. Time-boxed; auto-lifts when the clock passes SuspendedUntil.
POST /env/users/{id}/unsuspend — lifts an active suspension before it expires.
DELETE /env/users/{id}{"reason":"..."}. Soft-delete: row survives for audit, but is hidden from register / login lookups. Email + username become available again.
POST /env/users/{id}/unlock — clear a failed-attempts lockout (different from disable / suspend).

Login-time error codes: user.disabled, user.suspended, user.deleted. Distinct codes so a client SDK can render the right UX (retry vs. contact-support).

Webhooks /env/webhooks env-client JWT

Subscribe HTTPS receivers to environment events. Authyon POSTs a signed JSON envelope every time a matching event fires; failed deliveries retry with backoff (5s → 30s → 5min → 30min → 2h, cap 6 attempts) and the endpoint auto-disables after 20 consecutive failures.

Envelope shape
{
  "id":             "evt_0f7c…",
  "type":           "user.registered",
  "created":        1721512345,
  "workspace_id":   "…",
  "environment_id": "…",
  "data": {
    "user_id": "…",
    "email":   "alice@acme.com",
    "username": "alice"
  }
}
Signature verification (Node)
// header: X-Authyon-Signature: t=1721512345,v1=abc123…
const [ts, v1] = header.split(',').map(p => p.split('=')[1]);
const expected = crypto.createHmac('sha256', secret)
  .update(`${ts}.${rawBody}`)
  .digest('hex');
if (!crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(v1)))
  return res.status(401).end();
if (Math.abs(Date.now()/1000 - Number(ts)) > 300)   // reject >5min
  return res.status(401).end();
Headers on every POST
Content-Type: application/json
X-Authyon-Signature: t=<unix>,v1=<hex> — HMAC-SHA256 of {t}.{body}
X-Authyon-Event: <event.type>
X-Authyon-Event-Id: <uuid> — dedupe key if the receiver saw the same event twice
X-Authyon-Delivery: <uuid> — this attempt's id for support tickets
Retries

2xx = success. 4xx (except 408 / 429) = permanent failure, no retry. Everything else retries with 5s / 30s / 5min / 30min / 2h backoff up to 6 total attempts, then marks the delivery failed and ticks the endpoint's consecutive-failure counter.

Managing endpoints
GET /platform/…/webhooks
POST /platform/…/webhooks{"url":"https://…","eventTypes":["user.registered"],"description":"…"}
PATCH /platform/…/webhooks/{id}
DELETE /platform/…/webhooks/{id}
POST /platform/…/webhooks/{id}/rotate — rotate signing secret
POST /platform/…/webhooks/{id}/test — enqueue synthetic webhook.test
GET /platform/…/webhooks/{id}/deliveries?limit=50
GET /platform/webhooks/event-types — canonical event catalog (for the UI's selector)
CRUD /env/blocked-ips env-client JWT

Read-side view of the per-environment auto IP blocklist. The list is populated by the login pipeline itself: when a request for a disabled or suspended account clears password verification, the caller's IP is fingerprinted here for the TTL configured in the environment settings (AutoBlockDurationMinutes). Any subsequent login attempt from that IP short-circuits with environment.ip_blocked before user resolution.

Enable auto-blocking (env setting)
PUT /env/settings
{
  "autoBlockDurationMinutes": 60   // 1..10080 (7d); null disables
}
GET /env/blocked-ips?activeOnly=true — list current entries. Pass activeOnly=false to also see expired rows for forensic review.
DELETE /env/blocked-ips/{id} — manual unblock (escape hatch for the shared-NAT / VPN false-positive case).

Row shape: { id, ip, reason, userId?, blockedAt, expiresAt, isActive }. reason is user_disabled or user_suspended. userId is populated when the block was triggered by a known user probe.

CRUD /env/tenants env-client JWT

Manage tenants and their memberships programmatically.

GET /env/tenants
POST /env/tenants{"name":"Acme","slug":"acme"}
GET /env/tenants/{id}
PUT /env/tenants/{id}
DELETE /env/tenants/{id}
GET /env/tenants/{id}/members
POST /env/tenants/{id}/members{"userId":"..."}
DELETE /env/tenants/{id}/members/{userId}
POST /env/tenants/{id}/members/{userId}/roles
POST /env/tenants/{id}/members/{userId}/permissions
CRUD /env/roles · /env/permissions env-client JWT

Create custom env-level roles and permissions, and mirror the same shape for tenant-scoped roles under /env/tenants/{id}/roles.

POST /tenant/oauth/token OAuth client_credentials

Same shape as /env/oauth/token, but the resulting JWT is scoped to one tenant. Cannot see other tenants.

CRUD /tenant/* tenant-client JWT

Tenant-scoped mirror of /env/* — same routes, restricted to a single tenant.

In your API

Trust the tokens Authyon issues

When your API receives a request with Authorization: Bearer …, you need to answer two questions: was this really issued by Authyon, and is it still valid. You have three options depending on how up-to-date you need the answer:

  • JWKS — cheapest, verifies the signature offline against Authyon's public key. Perfect for high-traffic paths where "was signed by us" is enough.
  • Introspect — the standard OAuth 2.0 introspection endpoint. Use with API gateways / reverse proxies that speak this language natively.
  • Validate — the freshest. Cross-checks the user against our current state (are they disabled? did they lose access to the tenant?). Slower, but the answer is authoritative.
GET /.well-known/jwks.json Public

Public keys for signature verification. Cache for 10 min. Rotates when you rotate an environment key.

GET /.well-known/openid-configuration Public

OIDC discovery metadata (issuer, jwks_uri, endpoints). Point any OIDC-compatible library at this.

POST /auth/introspect Publishable key

Standard token introspection. Compatible with any API gateway (Kong, Envoy, custom).

Response — 200
{ "active": true, "sub": "...", "exp": 1735689600, "scope": "..." }
POST /auth/validate Publishable key

Recommended. Verifies the JWT AND cross-checks the current user snapshot against the authorization store — returns 401 the instant a role changes or the user is disabled, without waiting for token expiry.

Response — 200
{
  "user": { "id": "...", "roles": [...], "permissions": [...] },
  "tenant": { "id": "...", "slug": "..." }
}
GET /auth/forward-auth Publishable key

Header-based auth for any reverse proxy (Traefik forwardAuth, Nginx auth_request, custom). Returns 200 with X-User-* headers on success, 401 otherwise.

SDKs

Native client packages

Official SDKs are on the roadmap for every major frontend framework and backend runtime. See installation snippets, planned APIs, and roadmap on the dedicated page.

Browse SDKs →

Need something not listed?

Anything under /platform/* covers workspace-level operations (audit, admin, credentials) — those live in the dashboard rather than the customer API. Ask if you have a use case that needs them programmatically.

Create an account