Which section is for me?
Pick the role that matches what you're building. Each card jumps to the exact section you need.
Web app, mobile app, or SPA that lets people sign in and use your product.
Read: Core concepts → Auth flows → Login → Refresh.
Your API needs to check who the caller is and (maybe) manage users on their behalf.
Read: Verify tokens → Server-to-server → User management → Webhooks.
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:
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.
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.
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.
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.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/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.
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.
/auth/register
Publishable keyCreates 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.
{
"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— 409user.username.taken— 409 (only under the Reject policy)user.username.empty— 400 (username omitted under a non-Auto policy)user.password_weak— 400user.password_pwned— 400 (breach-check validation)
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 (alice→alice2).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.com→alice.dev) and applies the suffix ladder on conflict.
/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.
{
"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.
- 10 req / 5min / IP
- 5 req / 5min / email — protects against credential stuffing
/auth/refresh
Publishable keyTrade 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.
{ "refreshToken": "rt_..." }
Response — 200
{
"accessToken": "eyJ...",
"refreshToken": "rt_...",
"expiresIn": 1800
}/auth/logout
Publishable keyRevoke a refresh token. Body optional — with a bearer JWT and no body, revokes every refresh token for the user (logout-everywhere).
{ "refreshToken": "rt_..." }
Response — 204 No Content/auth/me
Bearer JWTReturn the fresh profile of the current user — pulled from the in-memory snapshot, so role/permission changes propagate instantly.
{
"id": "01H...",
"email": "alice@acme.com",
"tenants": [{ "id": "...", "slug": "acme", "roles": ["admin"] }],
"activeTenant": "acme",
"permissions": ["orders:read", "orders:write"]
}/auth/tenants
Bearer JWTList every tenant the user belongs to — used to render a tenant switcher. Returns { id, slug, name }.
/auth/tenants/{tenantId}/*
Bearer JWTThe "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/registerlink 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— 404tenant.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/registerfirst)tenant.member.already_exists— 409tenant.member.cannot_remove_self— 400
/auth/switch-tenant
Bearer JWTChange the active tenant on the current session and issue a fresh access token scoped to the new tenant.
{ "tenantSlug": "other-org" }/auth/sessions
Bearer JWTList active refresh-token sessions (device, IP, last active). Use in a profile page for "sign out of other devices".
DELETE /auth/sessions/{sessionId}/auth/password-reset/request
Publishable keyStart the forgot-password flow. Always returns 204, even when the email doesn't exist — avoids leaking account existence.
{ "email": "alice@acme.com" }/auth/password-reset/confirm
Publishable keyRedeem the token from the email link and set a new password. Revokes every existing refresh token — post-reset the user re-signs in everywhere.
{
"token": "prt_...",
"newPassword": "NewS3cure!"
}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.
/auth/2fa/status
Bearer JWTWhich methods (if any) the user has enrolled + remaining recovery codes.
/auth/2fa/authenticator/setup
Bearer JWTStart TOTP enrolment. Returns an inline SVG QR code and the base32 secret — display both to the user.
{
"secret": "JBSWY3DPEHPK3PXP",
"qrSvg": "<svg xmlns=...",
"otpauthUri": "otpauth://totp/Authyon:..."
}/auth/2fa/authenticator/confirm
Bearer JWTFinish enrolment by presenting the first 6-digit code. Response includes single-use recovery codes — show them once, they can never be recovered again.
{ "code": "123456" }
Response — 200
{
"recoveryCodes": ["a1b2-c3d4-...", ...] // 10 codes, single-use
}/auth/2fa/challenge
Publishable keyComplete a login that came back with twoFactorRequired: true.
{
"challengeId": "chg_...",
"code": "123456", // or "recoveryCode": "..."
"method": "authenticator" // or "email"
}
Response — 200: same tokens envelope as /auth/login.
/auth/2fa/recovery-codes/regenerate
Bearer JWT + step-upReplace the entire recovery code set. Requires a step-up (re-entered password or fresh 2FA) via X-Authyon-StepUp cookie.
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.
/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.
grant_type=client_credentials client_id=env_... client_secret=...Response — 200
{
"access_token": "eyJ...",
"token_type": "Bearer",
"expires_in": 3600,
"scope": "env:admin"
}/env/users
env-client JWTFull user management: create, list, read, assign role, grant permission, unlock, assign tenant.
GET /env/users — list, paginatedPOST /env/users — create (skips email verification)GET /env/users/{id} — full profilePOST /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 lockoutPOST /env/users/{id}/tenants — {"tenantId":"..."}DELETE /env/users/{id}/tenants/{tenantId}/env/users/{id}/{action}
env-client JWTAdmin 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).
/env/webhooks
env-client JWTSubscribe 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.
{
"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/jsonX-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 twiceX-Authyon-Delivery: <uuid> — this attempt's id for support tickets2xx = 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.
GET /platform/…/webhooksPOST /platform/…/webhooks — {"url":"https://…","eventTypes":["user.registered"],"description":"…"}PATCH /platform/…/webhooks/{id}DELETE /platform/…/webhooks/{id}POST /platform/…/webhooks/{id}/rotate — rotate signing secretPOST /platform/…/webhooks/{id}/test — enqueue synthetic webhook.testGET /platform/…/webhooks/{id}/deliveries?limit=50GET /platform/webhooks/event-types — canonical event catalog (for the UI's selector)/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.
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.
/env/tenants
env-client JWTManage tenants and their memberships programmatically.
GET /env/tenantsPOST /env/tenants — {"name":"Acme","slug":"acme"}GET /env/tenants/{id}PUT /env/tenants/{id}DELETE /env/tenants/{id}GET /env/tenants/{id}/membersPOST /env/tenants/{id}/members — {"userId":"..."}DELETE /env/tenants/{id}/members/{userId}POST /env/tenants/{id}/members/{userId}/rolesPOST /env/tenants/{id}/members/{userId}/permissions/env/roles · /env/permissions
env-client JWTCreate custom env-level roles and permissions, and mirror the same shape for tenant-scoped roles under /env/tenants/{id}/roles.
/tenant/oauth/token
OAuth client_credentialsSame shape as /env/oauth/token, but the resulting JWT is scoped to one tenant. Cannot see other tenants.
/tenant/*
tenant-client JWTTenant-scoped mirror of /env/* — same routes, restricted to a single tenant.
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.
/.well-known/jwks.json
PublicPublic keys for signature verification. Cache for 10 min. Rotates when you rotate an environment key.
/.well-known/openid-configuration
PublicOIDC discovery metadata (issuer, jwks_uri, endpoints). Point any OIDC-compatible library at this.
/auth/introspect
Publishable keyStandard token introspection. Compatible with any API gateway (Kong, Envoy, custom).
{ "active": true, "sub": "...", "exp": 1735689600, "scope": "..." }/auth/validate
Publishable keyRecommended. 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.
{
"user": { "id": "...", "roles": [...], "permissions": [...] },
"tenant": { "id": "...", "slug": "..." }
}/auth/forward-auth
Publishable keyHeader-based auth for any reverse proxy (Traefik forwardAuth, Nginx auth_request, custom). Returns 200 with X-User-* headers on success, 401 otherwise.
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.