DEVELOPERS · v0.4.0 · 399 commands

One API. One binary.
Every auth primitive you need.

Bastionary exposes every capability — login, signup, MFA, sessions, users, teams, webhooks, licenses, payments, SMS, email — through a single POST /api/v1/execute endpoint. No SDK required. Copy the snippets on this page and ship.

1 · QUICKSTART

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.

cURL
JavaScript
Python
Rust
# 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":{}}'
const BASE = "https://app.bastionary.com/api/v1/execute";

async function exec(command, params = {}, token = null) {
  const headers = { "Content-Type": "application/json" };
  if (token) headers.Authorization = `Bearer ${token}`;
  const r = await fetch(BASE, {
    method: "POST", headers,
    body: JSON.stringify({ command, params }),
  });
  const body = await r.json();
  if (!body.ok) throw new Error(body.error.message);
  return body.data;
}

// Log in, then call a protected command
const { access_token } = await exec("AUTH.LOGIN", {
  email: "you@example.com",
  password: "CorrectHorseBatteryStaple!",
});
const me = await exec("USER.ME", {}, access_token);
console.log(me);
import httpx

BASE = "https://app.bastionary.com/api/v1/execute"

def exec_cmd(command, params=None, token=None):
    headers = {"Content-Type": "application/json"}
    if token:
        headers["Authorization"] = f"Bearer {token}"
    r = httpx.post(BASE, json={"command": command, "params": params or {}}, headers=headers)
    body = r.json()
    if not body["ok"]:
        raise RuntimeError(body["error"]["message"])
    return body["data"]

# Log in, then call a protected command
tokens = exec_cmd("AUTH.LOGIN", {
    "email": "you@example.com",
    "password": "CorrectHorseBatteryStaple!",
})
me = exec_cmd("USER.ME", token=tokens["access_token"])
print(me)
use serde_json::json;

async fn exec_cmd(
    client: &reqwest::Client,
    command: &str,
    params: serde_json::Value,
    token: Option<&str>,
) -> anyhow::Result<serde_json::Value> {
    let mut req = client
        .post("https://app.bastionary.com/api/v1/execute")
        .json(&json!({ "command": command, "params": params }));
    if let Some(t) = token {
        req = req.bearer_auth(t);
    }
    let body: serde_json::Value = req.send().await?.json().await?;
    if body["ok"] == false {
        anyhow::bail!("{}", body["error"]["message"]);
    }
    Ok(body["data"].clone())
}
2 · RESPONSE ENVELOPE

Every response has the same shape

Successes and failures share a single envelope. Your client code only needs to branch on ok.

Success
{
  "ok": true,
  "command": "USER.ME",
  "data": {
    "id": "usr_01HX…",
    "email": "you@example.com",
    "is_superadmin": false
  },
  "error": null
}
Failure
{
  "ok": false,
  "command": "AUTH.LOGIN",
  "data": null,
  "error": {
    "code": "AUTH_ERROR",
    "message": "Invalid credentials"
  }
}
AUTHENTICATION FLOW

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.

1. Login
AUTH.LOGIN
email + password → access & refresh tokens, MFA challenge if required.
2. Use
Authorization: Bearer …
Send the access token on every protected command. Superadmins bypass all permission checks.
3. Refresh
AUTH.REFRESH
When the access token expires (HTTP error TOKEN_EXPIRED), rotate it using the refresh token.

Authentication commands

Primitives for login, signup, MFA, password resets, and OAuth bridges.

AUTH.SIGNUPCreate account + tokens
AUTH.LOGINPassword login → tokens
AUTH.REFRESHRotate access token
AUTH.LOGOUTRevoke current session
AUTH.FORGOT_PASSWORDSend reset email
AUTH.RESET_PASSWORDConsume reset token
AUTH.MFA_SETUP_TOTPBegin TOTP enrollment
AUTH.MFA_VERIFYSubmit 2FA code

Users

CRUD, role changes, avatar uploads, profile fields. Superadmins get the full list; users get self-service.

USER.MEGet the caller's profile
USER.LISTPaginated user list
USER.UPDATEPatch display_name, avatar
USER.CHANGE_PASSWORDRotate password in-session
USER.DELETEHard delete account
USER.SET_ROLEAssign RBAC role

Sessions

Every active login is a session record. Revoke per-device or revoke-all on password change.

SESSION.LISTSessions for current user
SESSION.REVOKEKill one session by id
SESSION.REVOKE_ALLKill every session except current

Teams & RBAC

Multi-tenant workspaces with per-team roles and fine-grained permissions.

TEAM.CREATECreate workspace
TEAM.INVITEEmail invite with role
TEAM.LIST_MEMBERSRoster of a workspace
ROLE.LISTAvailable roles
PERMISSION.CHECKCan user X do Y on Z?

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.

FLOW.LISTAll flows in the workspace
FLOW.GETOne flow by name
FLOW.CREATEDefine a new DAG
FLOW.UPDATEPatch nodes / edges
FLOW.DELETERemove a flow
FLOW.TESTDry-run against a context

Webhooks

HMAC-signed, at-least-once outbound deliveries with automatic retries and a dead-letter view.

WEBHOOK.CREATERegister endpoint + secret
WEBHOOK.LISTConfigured endpoints
WEBHOOK.DELIVERY_LISTDelivery attempts + status
WEBHOOK.REPLAYRetry a failed delivery

Licensing

Software licenses with feature flags, seat counts, expiration, and offline activation. Replaces Keygen / Cryptlex.

LICENSE.ISSUEMint a new license key
LICENSE.VALIDATEVerify + bump last_seen
LICENSE.REVOKEKill an active key
LICENSE.TRANSFERMove a key between machines

Payments

5 processors wired: Stripe, Paddle, Lemon Squeezy, Paypal, Mollie. One API, pick the vendor per product.

BILLING.CHECKOUT_CREATEHosted checkout session
BILLING.SUBSCRIPTION_LISTActive subscriptions
BILLING.INVOICE_LISTHistory with PDFs
BILLING.REFUNDFull or partial refund

All 399 commands

Searchable reference. Click any command to copy its fully-qualified name.

Loading registry from /api/v1/commands…

Error codes

Every failure returns one of these in error.code. They're stable — safe to branch on.

CodeMeaning
AUTH_ERRORInvalid credentials, expired session, or MFA required.
TOKEN_EXPIREDAccess token expired — call AUTH.REFRESH with your refresh token.
TOKEN_INVALIDToken signature failed or was rotated out.
PERMISSION_DENIEDAuthenticated, but the user lacks the required role/permission.
NOT_FOUNDReferenced resource does not exist or is not visible to the caller.
VALIDATION_ERRORRequest body failed schema validation. See message for details.
RATE_LIMITEDToo many requests. Back off per the Retry-After header.
FLOW_BLOCKEDYour configured flow denied this request — inspect flow.reason.
UNKNOWN_COMMANDCommand name not in the registry. Check spelling and case.
INTERNAL_ERRORBastionary 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

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.

WEBAUTHN.REGISTER_OPTIONSGenerate registration challenge + RP options
WEBAUTHN.REGISTER_VERIFYVerify attestation and persist credential
WEBAUTHN.LOGIN_OPTIONSGenerate authentication challenge
WEBAUTHN.LOGIN_VERIFYVerify assertion and mint session tokens
WEBAUTHN.LIST_CREDENTIALSList registered passkeys for account settings UI
WEBAUTHN.REVOKERevoke a lost or replaced credential
// 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 }
  })
});
Registration ceremony

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.

Authentication ceremony

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.

Fallback flows

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.

Attestation and recovery

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.

SOCIAL

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.

  1. Create an OAuth app in the upstream provider console.
  2. Set the callback URL to the Bastionary social callback for that provider.
  3. Use SOCIAL.AUTH_URL to start the login and SOCIAL.CALLBACK to complete it inside the unified auth flow.
Provider Redirect URI Scopes used Notes
Google /api/auth/social/google/callback openid email profile Best default for consumer SaaS and workforce accounts.
GitHub /api/auth/social/github/callback user:email Use for developer audiences and OSS-heavy products.
Discord /api/auth/social/discord/callback identify email Useful for community-led products and support portals.
Microsoft / Apple Not exposed in the current router set N/A Treat as unsupported on this build; do not advertise until routes and provider handlers exist.
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.

M2M / SERVICE ACCOUNTS

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.

Step 1 — Create a service account
# 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_..." }
Step 2 — Exchange credentials for a token
# 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"]
Step 3 — Call the API
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}'
DPoP hardening (recommended for agents and internal services)
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": "..." }
  }
}
Rotate the client secret
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": "..."}.

Step 4 — Introspect a token
# 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.

Step 5 — Revoke a token
# 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-..." }
M2M.TOKENClient-credentials token issuance
M2M.INTROSPECTValidate token state and claims
M2M.REVOKERevoke a token by JTI
DPOP.REGISTERBind a public key to the caller
DPOP.VERIFYValidate a proof JWT against method/URL
DPOP.LISTList current bindings
DPOP.REVOKERevoke a binding
DPOP.THUMBPRINTCompute RFC 7638 JWK thumbprint

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.

MIGRATION

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.

  1. Export users — Use the Auth0 Management API: GET /api/v2/users?per_page=100. Export email, sub, app_metadata, and user_metadata fields.
  2. Import users — Call USER.BULK_IMPORT with the exported JSON. Map: sub → external_id, email → email, app_metadata → metadata_json.
  3. Update OIDC config — Replace your Auth0 domain (yourapp.auth0.com) with app.bastionary.com in your application config.
  4. Swap SDK — Replace auth0 package with @bastionary/sdk. The command surface maps 1:1 for login, signup, token refresh, and MFA.
  5. 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.

DEPLOYMENT

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
Key environment variables
DATABASE_URLPostgreSQL async DSN (required)
JWT_SECRET32+ byte secret for JWT signing (required)
BASTION_HOSTPublic URL of your instance
BASTION_ENABLE_PUBLIC_REGISTRATIONSet false to disable self-signup
BASTION_ENCRYPTION_KEYFernet key for PII field encryption (optional)
STRIPE_SECRET_KEYRequired only if using Stripe billing

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.

Kubernetes deployment

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 & restore
# 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

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
middleware.ts
import { bastionaryMiddleware } from 'bastionary-js/next'
export default bastionaryMiddleware({
  publicRoutes: ['/', '/about'],
  loginUrl: '/login',
})
export const config = { matcher: ['/((?!_next|static|favicon).*)'] }
app/api/auth/[...bastionary]/route.ts
import { handleAuth } from 'bastionary-js/next'
export const { GET, POST } = handleAuth()
app/dashboard/page.tsx — read the session
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

React SPA Quickstart

Client-side React with Vite or CRA. Token stored in memory (not localStorage) for XSS protection.

# Install
npm install bastionary-js
main.tsx — wrap your app
import { BastionaryProvider } from 'bastionary-js/react'
import App from './App'

createRoot(document.getElementById('root')!).render(
  <BastionaryProvider url="https://app.bastionary.com">
    <App />
  </BastionaryProvider>
)
Anywhere in your app
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

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
auth.py — JWT verification middleware
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

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
auth/middleware.go
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)
    })
}