Home Playground Docs hub

Guide G-01 · Platform-wide

Authentication & sessions

Verified against local · 2026-09-23 · auth-service 3.0.1 (workspace HEAD)
Journey. As a developer, I want my users to sign in, hold a session, and sign
out cleanly, so that my app can act on their behalf without embedding credentials.

The Auth Service is the platform's front door. This guide covers the full session lifecycle: discover what sign-in methods a tenant supports, complete a sign-in, use the resulting tokens, rotate them before they expire, and retire them at sign-out.

Prerequisites

guide G-00. All Auth Service endpoints except health require it.

refresh token there; without it both calls fail with 500 Failed to store refresh token in MemCache.

Concepts (only what this journey needs)

app is calling (vendor auth); the JWT proves which user the call is for (user identity). Most endpoints in other services require both; the Auth Service's sign-in endpoints require the key pair, and token-gated endpoints (logout) add the JWT.

Google, …) all produce the same result: a token pair.

the refresh token rotates on every use — a successful refresh returns a new pair and invalidates the old refresh token.

when the service runs with INAYA_CHECK_EXPIRED_JWTS=true).

Step 1 — Discover the tenant's sign-in methods

Before showing any UI, ask the Auth Service which methods this tenant supports:

bash
curl -s -X POST "${BASE_URL}/v3/auth/login_methods" \
  -H "Content-Type: application/json" \
  -H "x-api-key: ${INAYA_API_KEY}" \
  -H "x-client-app-key: ${INAYA_APP_KEY}" \
  -d '{"locale": "en-US"}'
python
import os, json, urllib.request

req = urllib.request.Request(
    f"{os.environ['BASE_URL']}/v3/auth/login_methods",
    data=json.dumps({"locale": "en-US"}).encode(),
    headers={
        "Content-Type": "application/json",
        "x-api-key": os.environ["INAYA_API_KEY"],
        "x-client-app-key": os.environ["INAYA_APP_KEY"],
    },
)
print(json.dumps(json.load(urllib.request.urlopen(req)), indent=2))
javascript
const res = await fetch(`${process.env.BASE_URL}/v3/auth/login_methods`, {
  method: "POST",
  headers: {
    "Content-Type": "application/json",
    "x-api-key": process.env.INAYA_API_KEY,
    "x-client-app-key": process.env.INAYA_APP_KEY,
  },
  body: JSON.stringify({ locale: "en-US" }),
});
console.log(await res.json());

Expected response — everything the tenant supports, plus the subset enabled for the request's region:

json
{ "all_methods": ["FederatedApple", "FederatedGoogle", "Passkey"], "regional_methods": ["FederatedApple"] }

Render your sign-in UI from regional_methods; fall back to all_methods when it is empty.

Step 2 — Sign in

All flows follow the same shape: begin (server creates a challenge) → complete with the identity provider → finish (server verifies and issues the token pair). The middle step depends on the method:

the browser/platform authenticator signs them; POST /v3/auth/pass_key/login/finish verifies the signature and returns the token pair. (Registration uses the same begin/finish pair under /v3/auth/pass_key/register/* — the Playground's Users console exercises it live.)

identity token (Apple/Google SDK on the client) for the platform token pair.

returns a real token pair with no external identity provider. It exists for integration tests and local development; it is not available in production.

Kick off a passkey sign-in (this first half is automatable; the authenticator response is not):

bash
curl -s -X POST "${BASE_URL}/v3/auth/pass_key/login/begin" \
  -H "Content-Type: application/json" \
  -H "x-api-key: ${INAYA_API_KEY}" \
  -H "x-client-app-key: ${INAYA_APP_KEY}" \
  -d "{\"device_id\": \"my-device-001\", \"locale\": \"en-US\", \"tenant_id\": \"${TENANT_ID}\"}"

Expected response — a challenge_id plus WebAuthn assertion options (challenge, allowed credentials) to hand to the client's WebAuthn API (navigator.credentials.get):

json
{ "challenge_id": "…", "options": { "challenge": "…", "rpId": "…", "allowCredentials": [{ "id": "…", "type": "public-key" }] } }

NOTE

Manual step. Pass options to the authenticator, then submit the signed assertion to POST /v3/auth/pass_key/login/finish (body: challenge_id, the PublicKeyCredential JSON as credential, plus device_id, locale, and tenant_id). The response is the token pair below. Federated sign-in is likewise a manual walkthrough: obtain the provider token client-side, then POST /v3/auth/federated/login with it. Neither step can be executed by a script — that is the point of the credential.

In local and QA environments you can shortcut to a real token pair for testing:

bash
curl -s -X POST "${BASE_URL}/v3/auth/qa/login" \
  -H "Content-Type: application/json" \
  -H "x-api-key: ${INAYA_API_KEY}" \
  -H "x-client-app-key: ${INAYA_APP_KEY}" \
  -d "{\"device_id\": \"my-device-001\", \"locale\": \"en-US\", \"tenant_id\": \"${TENANT_ID}\", \"username\": \"dev-user\"}"
python
import os, json, urllib.request

req = urllib.request.Request(
    f"{os.environ['BASE_URL']}/v3/auth/qa/login",
    data=json.dumps({
        "device_id": "my-device-001",
        "locale": "en-US",
        "tenant_id": os.environ["TENANT_ID"],
        "username": "dev-user",
    }).encode(),
    headers={
        "Content-Type": "application/json",
        "x-api-key": os.environ["INAYA_API_KEY"],
        "x-client-app-key": os.environ["INAYA_APP_KEY"],
    },
)
pair = json.load(urllib.request.urlopen(req))
print(json.dumps(pair, indent=2))
javascript
const res = await fetch(`${process.env.BASE_URL}/v3/auth/qa/login`, {
  method: "POST",
  headers: {
    "Content-Type": "application/json",
    "x-api-key": process.env.INAYA_API_KEY,
    "x-client-app-key": process.env.INAYA_APP_KEY,
  },
  body: JSON.stringify({
    device_id: "my-device-001",
    locale: "en-US",
    tenant_id: process.env.TENANT_ID,
    username: "dev-user",
  }),
});
console.log(await res.json());

Expected response — the token pair every successful sign-in returns:

json
{
  "token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.…",
  "refresh_token": "9f1c…",
  "user": { "id": "…", "username": "dev-user" }
}

Step 3 — Hold and present the session

secure enclave) — never in logs, URLs, or source control.

endpoint, alongside the key-pair headers. One without the other is rejected.

as the expiry signal and refresh (Step 4).

Step 4 — Refresh before expiry

Exchange the refresh token for a fresh pair. Refresh tokens rotate: the old refresh token stops working the moment this call succeeds.

bash
curl -s -X POST "${BASE_URL}/v3/auth/refresh" \
  -H "Content-Type: application/json" \
  -H "x-api-key: ${INAYA_API_KEY}" \
  -H "x-client-app-key: ${INAYA_APP_KEY}" \
  -d "{\"device_id\": \"my-device-001\", \"locale\": \"en-US\", \"refresh_token\": \"${REFRESH_TOKEN}\", \"tenant_id\": \"${TENANT_ID}\"}"
python
import os, json, urllib.request

req = urllib.request.Request(
    f"{os.environ['BASE_URL']}/v3/auth/refresh",
    data=json.dumps({
        "device_id": "my-device-001",
        "locale": "en-US",
        "refresh_token": os.environ["REFRESH_TOKEN"],
        "tenant_id": os.environ["TENANT_ID"],
    }).encode(),
    headers={
        "Content-Type": "application/json",
        "x-api-key": os.environ["INAYA_API_KEY"],
        "x-client-app-key": os.environ["INAYA_APP_KEY"],
    },
)
print(json.dumps(json.load(urllib.request.urlopen(req)), indent=2))
javascript
const res = await fetch(`${process.env.BASE_URL}/v3/auth/refresh`, {
  method: "POST",
  headers: {
    "Content-Type": "application/json",
    "x-api-key": process.env.INAYA_API_KEY,
    "x-client-app-key": process.env.INAYA_APP_KEY,
  },
  body: JSON.stringify({
    device_id: "my-device-001",
    locale: "en-US",
    refresh_token: process.env.REFRESH_TOKEN,
    tenant_id: process.env.TENANT_ID,
  }),
});
console.log(await res.json());

Expected response — a new pair; replace both stored values:

json
{ "token": "eyJ…", "refresh_token": "c4aa…" }

PITFALL

A 403 here almost always means the refresh token was already rotated — another concurrent refresh won. Serialize refreshes per device: one in flight at a time, and always store the most recent pair the server returned.

Step 5 — Sign out

Retire the session server-side (requires the access token — note PUT):

bash
curl -s -X PUT "${BASE_URL}/v3/auth/logout" \
  -H "Authorization: Bearer ${TOKEN}" \
  -H "x-api-key: ${INAYA_API_KEY}" \
  -H "x-client-app-key: ${INAYA_APP_KEY}"
python
import os, json, urllib.request

req = urllib.request.Request(
    f"{os.environ['BASE_URL']}/v3/auth/logout",
    method="PUT",
    headers={
        "Authorization": f"Bearer {os.environ['TOKEN']}",
        "x-api-key": os.environ["INAYA_API_KEY"],
        "x-client-app-key": os.environ["INAYA_APP_KEY"],
    },
)
print(json.load(urllib.request.urlopen(req)))
javascript
const res = await fetch(`${process.env.BASE_URL}/v3/auth/logout`, {
  method: "PUT",
  headers: {
    Authorization: `Bearer ${process.env.TOKEN}`,
    "x-api-key": process.env.INAYA_API_KEY,
    "x-client-app-key": process.env.INAYA_APP_KEY,
  },
});
console.log(await res.json());

Expected response:

json
"Logged out successfully"

NOTE

When INAYA_CHECK_EXPIRED_JWTS=true the logout also records the token's jti in the retirement store, so the same JWT is rejected even before its natural expiry. Always discard both tokens locally regardless.

Full working example

A complete sign-in → refresh → logout cycle for local/QA environments (first_call.sh pattern from G-00, extended):

bash
#!/usr/bin/env bash
set -euo pipefail

# 1. Discover methods
curl -sf -X POST "${BASE_URL}/v3/auth/login_methods" \
  -H "Content-Type: application/json" \
  -H "x-api-key: ${INAYA_API_KEY}" \
  -H "x-client-app-key: ${INAYA_APP_KEY}" \
  -d '{"locale": "en-US"}' > /dev/null && echo "methods: ok"

# 2. Sign in (QA path — production uses passkey/federated begin+finish)
PAIRS=$(curl -sf -X POST "${BASE_URL}/v3/auth/qa/login" \
  -H "Content-Type: application/json" \
  -H "x-api-key: ${INAYA_API_KEY}" \
  -H "x-client-app-key: ${INAYA_APP_KEY}" \
  -d "{\"device_id\": \"my-device-001\", \"locale\": \"en-US\", \"tenant_id\": \"${TENANT_ID}\", \"username\": \"dev-user\"}")
TOKEN=$(echo "$PAIRS" | python3 -c 'import json,sys; print(json.load(sys.stdin)["token"])')
REFRESH=$(echo "$PAIRS" | python3 -c 'import json,sys; print(json.load(sys.stdin)["refresh_token"])')
echo "signed in: ok"

# 3. Refresh
curl -sf -X POST "${BASE_URL}/v3/auth/refresh" \
  -H "Content-Type: application/json" \
  -H "x-api-key: ${INAYA_API_KEY}" \
  -H "x-client-app-key: ${INAYA_APP_KEY}" \
  -d "{\"device_id\": \"my-device-001\", \"locale\": \"en-US\", \"refresh_token\": \"${REFRESH}\", \"tenant_id\": \"${TENANT_ID}\"}" > /dev/null \
  && echo "refresh: ok"

# 4. Sign out
curl -sf -X PUT "${BASE_URL}/v3/auth/logout" \
  -H "Authorization: Bearer ${TOKEN}" \
  -H "x-api-key: ${INAYA_API_KEY}" \
  -H "x-client-app-key: ${INAYA_APP_KEY}" > /dev/null && echo "logout: ok"

Try it live

Open the Playground → Users & Profile Matching → Users console — its passkey-registration flow exercises the begin/finish contract against the live Auth Service.

Copy agent prompt

Agent prompt — copy and hand to your agent
You are integrating with the Inaya Labs Auth Service at ${BASE_URL}. The tenant
key pair is in INAYA_API_KEY / INAYA_APP_KEY env vars (x-api-key /
x-client-app-key headers). Tenant ID: ${TENANT_ID}.
Steps: (1) POST /v3/auth/login_methods with {"locale":"en-US"} and report the
regional methods. (2) This is a QA environment: obtain a token pair via POST
/v3/auth/qa/login {"device_id":"agent-device","locale":"en-US","tenant_id":TENANT_ID,"username":"agent-user"}.
(3) Refresh it once via POST /v3/auth/refresh with the returned refresh_token
and the same tenant_id.
(4) Sign out via PUT /v3/auth/logout with Authorization: Bearer <token>.
Never log the tokens; report only success/failure per step.

API reference

Next journeys