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
- The Auth Service is reachable (local stack
:40004, or the shared dev subdomain). - Your tenant key pair (
x-api-key/x-client-app-key) — the same pair from
guide G-00. All Auth Service endpoints except health require it.
- Memcache Service reachable (
:40011local) — sign-in and refresh store the
refresh token there; without it both calls fail with 500 Failed to store refresh token in MemCache.
curl, or any HTTP client. Snippets also show Python and Node.
Concepts (only what this journey needs)
- Two credential tiers, always together. The key pair proves which tenant
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.
- Sign-in is method-agnostic. Passkeys, and federated providers (Apple,
Google, …) all produce the same result: a token pair.
- Tokens are short-lived and rotatable. The access JWT expires in 24 hours;
the refresh token rotates on every use — a successful refresh returns a new pair and invalidates the old refresh token.
- Sign-out retires the JWT so it cannot be replayed (JWT retirement is active
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:
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"}'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))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:
{ "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:
- Passkey —
POST /v3/auth/pass_key/login/beginreturns WebAuthn options;
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.)
- Federated —
POST /v3/auth/federated/loginexchanges the provider's
identity token (Apple/Google SDK on the client) for the platform token pair.
- QA environments —
POST /v3/auth/qa/logincreates a user on the fly and
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):
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):
{ "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:
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\"}"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))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:
{
"token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.…",
"refresh_token": "9f1c…",
"user": { "id": "…", "username": "dev-user" }
}Step 3 — Hold and present the session
- Keep
tokenandrefresh_tokenin your app's secure storage (keychain,
secure enclave) — never in logs, URLs, or source control.
- Send the access token as
Authorization: Bearer <token>on every user-data
endpoint, alongside the key-pair headers. One without the other is rejected.
- Do not parse the JWT client-side to decide expiry; treat HTTP
401responses
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.
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}\"}"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))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:
{ "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):
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}"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)))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:
"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):
#!/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
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
- Calling Inaya services from an AI agent (G-02) — agent-facing MCP access, no token handling at all.
- Errors, retries & idempotency (G-03) — what to do when these calls fail.