Every request (other than the embeddable widget's own endpoint) authenticates one of two ways: a first-party JWT (your users signing in through your own front end) or an API key (server-to-server integrations). Both resolve to the same `RequestContext` — a tenant id, and either a membership or an API key's own permission subset — so every endpoint behaves identically regardless of which one called it.

## First-party JWT flow

Institflow is multi-tenant: one user account can hold memberships in several institutes, so signing in is a three-step handshake rather than a single call.

1. `POST /api/v1/auth/login` with `email`, `password`, and `client` (`"web"` or `"mobile"`). The response carries a short-lived `accessToken`, the signed-in `user`, and every active `membership` the account holds. If there is exactly one, `activeTenantId` is already set and you can skip step 2.
2. `POST /api/v1/auth/select-tenant` with the chosen `membershipId` — returns a new `accessToken` scoped to that tenant. Every subsequent request's tenant context comes from this token, never from a header or query parameter you set yourself.
3. `POST /api/v1/auth/refresh` rotates the refresh token and issues a fresh `accessToken` before the old one expires. A refresh token that gets presented twice (reuse — a strong signal it was stolen) revokes the whole session family immediately (`SESSION_REVOKED`); sign in again.

Send the access token as `Authorization: Bearer <accessToken>` on every request. `GET /api/v1/auth/me` returns the current user, their memberships, and their effective permission list for the active tenant — useful for building your own session UI without re-deriving permissions client-side.

## Secret API keys

For server-to-server integrations (a script, a backend job, a partner system) create a secret key instead: `POST /api/v1/api-keys` (requires `integration.manage`) with a `name` and a `permissions` array — a strict subset of your own effective permissions; you can never grant a key more than you yourself hold. The full key (`ifl_live_<32 random bytes, base64url>`) is returned exactly once, at creation — only its prefix and a hash are ever stored, so if you lose it, revoke it and create a new one.

```http
Authorization: Bearer ifl_live_9c1f2e7a4b8d3c6f0a5e9b2d7c4f1a8e
```

A key is bound to the tenant of the membership that created it and carries only its own `permissions` — not your full effective set — on every request it authenticates. Revoking a key (`DELETE /api/v1/api-keys/:id`) takes effect immediately, not after a cache expires.

## Publishable keys

A second key kind, `ifl_pub_<…>`, is safe to embed in front-end code — it can *only* call the `/api/v1/public/*` surface: `POST /public/registrations` (the endpoint the [registration widget](https://docs.institflow.com/en/widget) uses), `POST /public/leads` (contact-form enquiries), and the read-only course catalogue (`GET /public/courses`, `/public/courses/:id`, `/public/branches` — these answer 404 until the institute switches the public catalogue on). It is sent as `X-Api-Key`, is restricted to an explicit origin allowlist you configure per key, carries no staff permissions, and is rate-limited per IP and per key. Never use a publishable key for anything else — it deliberately cannot do anything else.

## Idempotency-Key

Every `POST`/`PUT`/`PATCH`/`DELETE` made with an API key **must** carry an `Idempotency-Key` header (any unique string — a UUID is the usual choice). First-party JWT callers may send one too, but it is optional for them.

- Missing on a key-authenticated mutation → `428` `IDEMPOTENCY_KEY_REQUIRED`.
- Same key, same method/path/body as before → the original response is replayed byte-for-byte (no re-execution — safe to retry a timed-out request blind).
- Same key, a *different* method/path/body → `409` `IDEMPOTENCY_KEY_REUSED` — you reused a key for a different request.
- Same key, arriving *while the first request is still running* → `409` `IDEMPOTENCY_KEY_REUSED`. The mutation runs once; retry after the first call returns.
- A `4xx` is remembered too — a deterministic failure replays rather than re-running. A `5xx` is **never** cached: retrying it really does re-execute, which is what you want after a server-side failure.

Keys are remembered for 24 hours.

> **The fingerprint includes the query string**
>
> Two requests count as “the same request” only when their method, their full URL *including the query string*, and their body are byte-for-byte identical — the fingerprint is `sha256(METHOD + "\n" + url + "\n" + body)`. A retry that reorders its query parameters is a *different* request to the same key, and gets a `409` instead of the replay you expected. Serialize your parameters the same way every time.

## Rate limits

Every request is rate-limited by a Redis fixed window — per IP when unauthenticated, per user for JWT callers, per key for API-key callers (600 requests/minute by default). A key can be given its own narrower budget with `rateLimitPerMin` on `POST /api/v1/api-keys` or `PATCH /api/v1/api-keys/{id}` — useful for a key you hand to a third party. It can never be *wider* than your plan's ceiling; asking for more is a `422`. Every response carries the current window's state, and `RateLimit-Limit` is always that key's own effective budget:

```http
RateLimit-Limit: 600
RateLimit-Remaining: 587
RateLimit-Reset: 42
```

Exceeding the limit returns `429` with error code `RATE_LIMITED` — back off until `RateLimit-Reset` seconds have elapsed.

> **Never share a secret key**
>
> A secret key (`ifl_live_…`) authenticates as whatever permissions it was granted — treat it like a password. Only a publishable key (`ifl_pub_…`) is safe in browser-visible code.
