Your first request in 60 seconds
Everything goes through one endpoint: POST /api/v1/execute. The body names the command you want and its params. That's it.
# 1. Sign up (or skip if you already have an account) curl "https://app.bastionary.com/api/v1/execute" \ -H "Content-Type: application/json" \ -d '{ "command": "AUTH.SIGNUP", "params": { "email": "you@example.com", "password": "CorrectHorseBatteryStaple!", "display_name": "You" } }' # 2. Log in → you get access_token + refresh_token curl "https://app.bastionary.com/api/v1/execute" \ -H "Content-Type: application/json" \ -d '{ "command": "AUTH.LOGIN", "params": { "email": "you@example.com", "password": "CorrectHorseBatteryStaple!" } }' # 3. Use the token on any protected command curl "https://app.bastionary.com/api/v1/execute" \ -H "Content-Type: application/json" \ -H "Authorization: Bearer $ACCESS_TOKEN" \ -d '{"command":"USER.ME","params":{}}'
Every response has the same shape
Successes and failures share a single envelope. Your client code only needs to branch on ok.
{
"ok": true,
"command": "USER.ME",
"data": {
"id": "usr_01HX…",
"email": "you@example.com",
"is_superadmin": false
},
"error": null
}
{
"ok": false,
"command": "AUTH.LOGIN",
"data": null,
"error": {
"code": "AUTH_ERROR",
"message": "Invalid credentials"
}
}
Tokens, sessions, refresh
AUTH.LOGIN returns a short-lived access_token (15 min default) and a long-lived refresh_token. Put the access token in the Authorization: Bearer header. When it expires, call AUTH.REFRESH with the refresh token to get a new pair. Risk scoring, MFA, IP allowlists, and rate limits are all enforced by the Flow engine — you configure it visually, no code changes.
Authentication commands
Primitives for login, signup, MFA, password resets, and OAuth bridges.
Users
CRUD, role changes, avatar uploads, profile fields. Superadmins get the full list; users get self-service.
Sessions
Every active login is a session record. Revoke per-device or revoke-all on password change.
Teams & RBAC
Multi-tenant workspaces with per-team roles and fine-grained permissions.
Flow engine
Model the auth pipeline visually at /flows. Every trigger (login, signup, refresh…) runs the enabled flow before the hardcoded path, so you can force MFA, deny by risk, or branch on IP allowlists without redeploying.
Webhooks
HMAC-signed, at-least-once outbound deliveries with automatic retries and a dead-letter view.
Licensing
Software licenses with feature flags, seat counts, expiration, and offline activation. Replaces Keygen / Cryptlex.
Payments
5 processors wired: Stripe, Paddle, Lemon Squeezy, Paypal, Mollie. One API, pick the vendor per product.
All 399 commands
Searchable reference. Click any command to copy its fully-qualified name.
Error codes
Every failure returns one of these in error.code. They're stable — safe to branch on.
| Code | Meaning |
|---|---|
| AUTH_ERROR | Invalid credentials, expired session, or MFA required. |
| TOKEN_EXPIRED | Access token expired — call AUTH.REFRESH with your refresh token. |
| TOKEN_INVALID | Token signature failed or was rotated out. |
| PERMISSION_DENIED | Authenticated, but the user lacks the required role/permission. |
| NOT_FOUND | Referenced resource does not exist or is not visible to the caller. |
| VALIDATION_ERROR | Request body failed schema validation. See message for details. |
| RATE_LIMITED | Too many requests. Back off per the Retry-After header. |
| FLOW_BLOCKED | Your configured flow denied this request — inspect flow.reason. |
| UNKNOWN_COMMAND | Command name not in the registry. Check spelling and case. |
| INTERNAL_ERROR | Bastionary misbehaved. Traces go to /status. |
Rate limits
Defaults are generous; tune per-endpoint from the admin dashboard.
- • AUTH.LOGIN — 10 req / minute per IP (spike-guarded)
- • AUTH.FORGOT_PASSWORD — 3 req / hour per email
- • General /api/v1/execute — 600 req / minute per token
- • Webhook redelivery — 5 attempts, exponential backoff capped at 1h
SDKs
Official SDKs are thin wrappers — they speak the same command envelope. You never lose access to the underlying protocol.
Passkeys & WebAuthn
Bastionary exposes the full WebAuthn ceremony as explicit API steps. Use it for first-factor passwordless sign-in, second-factor step-up, or account recovery hardening. The command surface below is the source of truth; avoid older AUTH-prefixed examples.
// 1. Begin registration const begin = await fetch('/api/v1/execute', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` }, body: JSON.stringify({ command: 'WEBAUTHN.REGISTER_OPTIONS' }) }).then(r => r.json()); // 2. Call the browser WebAuthn API with the returned publicKey options const cred = await navigator.credentials.create({ publicKey: begin.data.options }); // 3. Complete registration with the attestation payload await fetch('/api/v1/execute', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` }, body: JSON.stringify({ command: 'WEBAUTHN.REGISTER_VERIFY', params: { credential: cred } }) });
Authenticated user requests WEBAUTHN.REGISTER_OPTIONS, browser creates a credential, server verifies attestation with WEBAUTHN.REGISTER_VERIFY, then the credential becomes available for passwordless login or MFA.
Anonymous user requests WEBAUTHN.LOGIN_OPTIONS, signs the challenge with a synced passkey or hardware key, and exchanges the assertion through WEBAUTHN.LOGIN_VERIFY for tokens.
Pair passkeys with TOTP, email OTP, or recovery codes. If a user loses their authenticator, let them sign in through a secondary factor, then revoke the old credential with WEBAUTHN.REVOKE.
Store attestation results only if your compliance posture needs device provenance. Most SaaS apps can accept platform and roaming authenticators alike, then rely on recovery codes or step-up auth for account rescue.
Service Accounts & M2M Auth
Bastionary treats app registrations as OAuth clients. Create the app once, store the generated API key as the client secret, exchange it for an M2M token through M2M.TOKEN, and optionally bind that token to a DPoP key for sender-constrained access.
# Creates a client_id + client_secret pair POST /api/v1/execute { "command": "APP.REGISTER", "params": { "name": "demo-worker", "slug": "demo-worker", "description": "Background job client", "redirect_urls": [] } } // Response — persist both values immediately: { "app": { "id": "uuid", "slug": "demo-worker" }, "api_key": "sk_live_..." }
# curl curl -X POST https://app.bastionary.com/api/m2m/token \ -H "Content-Type: application/json" \ -d '{ "client_id": "demo-worker", "client_secret": "'"$API_KEY"'", "scopes": ["read"], "audience": "api", "ttl_seconds": 3600 }' # Python resp = requests.post("https://app.bastionary.com/api/m2m/token", json={ "client_id": os.environ["BASTION_CLIENT_ID"], "client_secret": os.environ["BASTION_CLIENT_SECRET"], "scopes": ["read"], "audience": "api", }) token = resp.json()["data"]["access_token"]
curl -X POST https://app.bastionary.com/api/v1/execute \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{"command":"USER.LIST","limit":50}'
POST /api/v1/execute
{
"command": "DPOP.REGISTER",
"params": {
"public_jwk": { "kty": "EC", "crv": "P-256", "x": "...", "y": "..." },
"ttl_hours": 24
}
}
POST /api/v1/execute
{
"command": "DPOP.THUMBPRINT",
"params": {
"public_jwk": { "kty": "EC", "crv": "P-256", "x": "...", "y": "..." }
}
}
POST /api/v1/execute
{
"command": "APP.ROTATE_KEY",
"params": {
"app_id": "<uuid from APP.REGISTER>"
}
}
Introspection validates an M2M token's signature, revocation state, and m2m token type. It requires no auth, so resource servers can call it freely to verify a presented token before honoring a request. An expired, revoked, or non-M2M token returns {"active": false, "error": "..."}.
# Introspect — no auth required curl -X POST https://app.bastionary.com/api/m2m/introspect \ -H "Content-Type: application/json" \ -d '{"token": "<access_token>"}' // data on success: { "active": true, "sub": "demo-worker", "azp": "demo-worker", "scope": "read", "exp": 1717000000, "iat": 1716996400, "jti": "3f9c1e2a-...", "client_name": "demo-worker" }
Revocation requires a superadmin Bearer token. It takes the token's jti — returned by M2M.TOKEN at issuance or surfaced by introspection — and takes effect immediately, so every later introspection of that jti returns active: false.
# Revoke — superadmin token required curl -X POST https://app.bastionary.com/api/m2m/revoke \ -H "Authorization: Bearer $ADMIN_TOKEN" \ -H "Content-Type: application/json" \ -d '{"jti": "<jti from token issuance>"}' // data on success: { "revoked": true, "jti": "3f9c1e2a-..." }
Security: Never commit the app API key / client secret to source control. Store it in env vars or a real secrets manager, rotate it with APP.ROTATE_KEY, and prefer DPoP for long-lived agents that can hold a private key safely.
Migrating from Auth0
Bastionary uses the same OIDC protocol and JWT structure as Auth0. Most migrations are under an hour — swap the domain, import users, update redirect URIs.
- Export users — Use the Auth0 Management API: GET /api/v2/users?per_page=100. Export email, sub, app_metadata, and user_metadata fields.
- Import users — Call USER.BULK_IMPORT with the exported JSON. Map: sub → external_id, email → email, app_metadata → metadata_json.
- Update OIDC config — Replace your Auth0 domain (yourapp.auth0.com) with app.bastionary.com in your application config.
- Swap SDK — Replace auth0 package with @bastionary/sdk. The command surface maps 1:1 for login, signup, token refresh, and MFA.
- Update redirect URIs — In your OAuth app config, update the allowed callback URL to your Bastionary instance. Test the full login → token → verify flow.
Keycloak and Clerk migrations follow the same pattern. Keycloak users: export the realm JSON and use USER.BULK_IMPORT. Clerk users: export via the Clerk Dashboard → Users → Export.
Self-Hosting & Deployment
One command gets you running. PostgreSQL 14+ required. Works on any Linux host, Docker, or Kubernetes.
# Docker one-liner
docker run -d -p 8400:8400 \
-e DATABASE_URL="postgresql+asyncpg://user:pass@host/bastionary" \
-e JWT_SECRET="$(openssl rand -hex 32)" \
-e BASTION_HOST="https://app.yourapp.com" \
bastionary/server:latest
HA setup: Run 2+ gunicorn workers (--workers 4) behind Caddy or nginx. A docker-compose.yml with PostgreSQL + Bastionary + Caddy is included in the repo.
The service is stateless — scale replicas freely. Use a managed PostgreSQL (RDS, Cloud SQL, Supabase, Neon). Store secrets in a Kubernetes Secret or external secrets operator.
# secret.yaml — never commit this, use sealed-secrets or external-secrets apiVersion: v1 kind: Secret metadata: name: bastionary-secrets type: Opaque stringData: DATABASE_URL: "postgresql+asyncpg://bastionary:pass@pg-host/bastionary" JWT_SECRET: "your-32-byte-secret" BASTION_HOST: "https://auth.yourapp.com" --- # deployment.yaml apiVersion: apps/v1 kind: Deployment metadata: name: bastionary spec: replicas: 2 selector: matchLabels: app: bastionary template: metadata: labels: app: bastionary spec: containers: - name: bastionary image: bastionary/server:latest ports: - containerPort: 8400 envFrom: - secretRef: name: bastionary-secrets livenessProbe: httpGet: path: /health port: 8400 initialDelaySeconds: 10 periodSeconds: 30 readinessProbe: httpGet: path: /health port: 8400 initialDelaySeconds: 5 periodSeconds: 10 resources: requests: memory: "256Mi" cpu: "250m" limits: memory: "1Gi" cpu: "1000m" --- # service.yaml apiVersion: v1 kind: Service metadata: name: bastionary spec: selector: app: bastionary ports: - port: 80 targetPort: 8400
# Backup (run from host or cron) pg_dump -Fc $DATABASE_URL > bastionary-$(date +%Y%m%d).dump # Restore pg_restore -d $DATABASE_URL bastionary-20260401.dump # Key rotation (JWT signing keys) — zero-downtime with key overlap curl -X POST $BASTION_HOST/api/v1/execute \ -H "Authorization: Bearer $ADMIN_TOKEN" \ -d '{"command":"JWT_KEY.ROTATE","algorithm":"ES256","overlap_hours":24}'
Next.js Quickstart
Add Bastionary auth to a Next.js 14+ app using the App Router. Works with Server Components and middleware.
# Install npm install bastionary-js # .env.local NEXT_PUBLIC_BASTIONARY_URL=https://app.bastionary.com BASTIONARY_APP_ID=your_app_id
import { bastionaryMiddleware } from 'bastionary-js/next' export default bastionaryMiddleware({ publicRoutes: ['/', '/about'], loginUrl: '/login', }) export const config = { matcher: ['/((?!_next|static|favicon).*)'] }
import { handleAuth } from 'bastionary-js/next' export const { GET, POST } = handleAuth()
import { getSession } from 'bastionary-js/next/server' export default async function Dashboard() { const session = await getSession() // { user, access_token } return <div>Hello {session.user.email}</div> }
React SPA Quickstart
Client-side React with Vite or CRA. Token stored in memory (not localStorage) for XSS protection.
# Install
npm install bastionary-js
import { BastionaryProvider } from 'bastionary-js/react' import App from './App' createRoot(document.getElementById('root')!).render( <BastionaryProvider url="https://app.bastionary.com"> <App /> </BastionaryProvider> )
import { useAuth } from 'bastionary-js/react' function Nav() { const { user, signIn, signOut, isLoading } = useAuth() if (isLoading) return null return user ? <button onClick={signOut}>Sign out {user.email}</button> : <button onClick={() => signIn()}>Sign in</button> }
Python / FastAPI Quickstart
Protect FastAPI routes with Bastionary JWT verification. Zero session storage — stateless verification using the JWKS endpoint.
# Install
pip install bastionary python-jose[cryptography] httpx
import httpx from jose import jwt, JWTError from fastapi import Depends, HTTPException, status from fastapi.security import OAuth2PasswordBearer BASTIONARY_URL = "https://app.bastionary.com" oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/token") _jwks_cache = None async def get_jwks(): global _jwks_cache if not _jwks_cache: async with httpx.AsyncClient() as c: r = await c.get(f"{BASTIONARY_URL}/.well-known/jwks.json") _jwks_cache = r.json()["keys"] return _jwks_cache async def require_user(token: str = Depends(oauth2_scheme)): keys = await get_jwks() try: payload = jwt.decode(token, keys, algorithms=["RS256"]) return payload except JWTError: raise HTTPException(status.HTTP_401_UNAUTHORIZED) # Usage: @app.get("/me") async def me(user = Depends(require_user)): return {"sub": user["sub"], "email": user.get("email")}
Go Quickstart
Verify Bastionary JWTs in any Go HTTP server. Uses standard library + golang-jwt/jwt.
# Install
go get github.com/golang-jwt/jwt/v5
go get github.com/MicahParks/keyfunc/v3
package auth import ( "net/http" "strings" keyfunc "github.com/MicahParks/keyfunc/v3" jwt "github.com/golang-jwt/jwt/v5" ) const jwksURL = "https://app.bastionary.com/.well-known/jwks.json" var jwks, _ = keyfunc.NewDefaultCtx(context.Background(), []string{jwksURL}) func RequireAuth(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { raw := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ") token, err := jwt.Parse(raw, jwks.Keyfunc) if err != nil || !token.Valid { http.Error(w, "Unauthorized", 401) return } next.ServeHTTP(w, r) }) }
Social Login Setup
Bastionary ships first-party social login routes for Google, GitHub, and Discord today. Each provider follows the same shape: generate an auth URL, redirect the browser, receive the provider callback, and run the shared post-auth pipeline so social logins still honor MFA, risk, and lockout policy.
POST /api/v1/execute { "command": "SOCIAL.AUTH_URL", "params": { "provider": "google", "redirect_uri": "https://app.bastionary.com/api/auth/social/google/callback" } }GET /api/auth/social/google GET /api/auth/social/google/callback?code=...&state=... POST /api/v1/execute { "command": "SOCIAL.CALLBACK", "params": { "provider": "google", "code": "<provider callback code>", "redirect_uri": "https://app.bastionary.com/api/auth/social/google/callback" } }Linked-account management lives under AUTH.SOCIAL_LINKED and AUTH.SOCIAL_UNLINK. On first social login, Bastionary normalizes the upstream profile into the same user/session pipeline as password login.