> For clean Markdown of any page, append .md to the page URL. > For a complete documentation index, see https://docs.finput.com.au/overview/building-a-client/authentication-and-sessions/llms.txt. > For AI client integration (Claude Code, Cursor, etc.), connect to the MCP server at https://docs.finput.com.au/_mcp/server. # Authentication and sessions Every endpoint except registration, login, and the password and verification flows requires a bearer credential in the `Authorization` header. There are two kinds. ## JWT sessions — for an app with a signed-in user This section is the whole contract: a client written from this page alone is correct. ### Signing in `POST /api/v1/auth/login` with the user's credentials and, optionally, whether to keep them signed in: ```json { "email": "broker@example.com", "password": "...", "remember_me": true } ``` It returns a token pair: ```json { "email": "broker@example.com", "tokens": { "access": "eyJhbGciOi...", "refresh": "eyJhbGciOi..." } } ``` Send the access token on every request: ```bash curl -H "Authorization: Bearer " \ https://api.finput.com.au/api/v1/billing/entitlements ``` ### When login is refused A refused login answers `401` with a `detail` to show the user and a `code` to branch on: ```json { "detail": "Unable to log in with the credentials provided.", "code": "invalid_credentials" } ``` | `code` | When | What the user can do | | --------------------- | --------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- | | `invalid_credentials` | The email is unknown, the password is wrong, or the account is locked after repeated failures | Check the details, or reset the password | | `email_not_verified` | The password is right, but the email has not been verified yet | Open the verification email, or ask for a new one with `POST /api/v1/auth/resend-verification` | | `account_deactivated` | The password is right, but an organisation admin has deactivated the account | Ask an admin of the organisation to reactivate it | Only a correct password gets `email_not_verified` or `account_deactivated`, so none of these codes tells a caller without the password whether an email is registered. Branch on `code`, not on the wording in `detail`. ### How long a session lasts A **session** is everything from one sign-in to the next. It ends at whichever of two limits comes first: * **Idle timeout.** Every refresh pushes the session's expiry to one idle period from now, so a session in use stays signed in and one left alone ends. * **Hard cap.** However active it is, a session ends this long after sign-in. Refreshing never moves it: every rotated refresh token carries the original sign-in time. `remember_me` at sign-in picks the pair, and it holds for the life of the session: | `remember_me` | Signed out after inactivity | Hard cap from sign-in | | ------------------ | --------------------------- | --------------------- | | `false` or omitted | 3 days | 14 days | | `true` | 30 days | 90 days | These are the current defaults. * **An access token lasts about an hour** (current default), and never outlives its session: near the end of a session, the access token expires when the session does. * **Don't hardcode any of these numbers.** They are server configuration. If you need to show when a session will end if left alone, read the `exp` claim of the latest refresh token; `session_start` is when the session signed in. A client that never reads a claim at all and simply follows the rules below is also correct. ### Carrying the tokens Login and refresh return both tokens in the JSON body. Refresh and logout take the refresh token in the body, and every request sends `Authorization: Bearer `. No cookies or CSRF handling are involved. ### Refreshing When the API answers `401`, exchange the refresh token at `POST /api/v1/auth/refresh` with `{"refresh": ""}`. The response is a new pair, `{"access": "...", "refresh": "..."}`. Every refresh **rotates** the refresh token: the one you sent is spent, and the response carries its successor. A spent token is not dead straight away. For a short **grace window** (currently 30 seconds) after it was spent, presenting it again returns **the same successor** the first refresh issued, not a new one. After the window it is refused with `401`. That window is what keeps a client simple. Concurrent refreshes converge, and a lost response can be retried. ### The rules a client implements 1. **On a `401` from any endpoint other than refresh**, refresh once, then retry the original request once with the new access token. If the retried request is also `401`, or the refresh itself is `401`, the session is over: discard both tokens and sign the user out. 2. **Store the refresh token from every refresh response**, replacing the one you sent. Concurrent refreshes return the same refresh token, so it does not matter which response you store last. 3. **Concurrent requests need no coordination.** Several requests that hit `401` together may each refresh with the same token; within the grace window they all receive the same successor and all succeed. Sharing one in-flight refresh ("single-flight") is a sensible optimisation, but correctness does not depend on it. 4. **A refresh that fails without a response may be retried** with the same token, promptly. If the server did rotate, the retry inside the grace window returns the same successor. Retry for a few seconds, not minutes. Once the window has passed, the answer is `401`. 5. **`429` from refresh is not a sign-out.** Refresh is rate limited per user, not per IP, so colleagues behind one office connection do not share a budget. Back off and try again. Only `401` ends a session. ### Signing out `POST /api/v1/auth/logout` with the access token as the bearer and `{"refresh": ""}` in the body ends the session immediately. There is no grace window on logout. If the refresh token you hold was spent moments ago by a concurrent refresh, logout still ends the session: it revokes the successor too. The access token stays valid until it expires, which is inherent to stateless JWT, so discard your own copy of both tokens whatever the response. A `400` means the refresh token was already unusable. The session is over either way. ### Where to keep the tokens * **Native apps:** in the platform's secure store (the iOS Keychain, or Android Keystore-backed encrypted storage). Never plain preferences or a file. * **Browser apps:** keep the access token in memory rather than `localStorage`, where an injected script could copy it. ## API tokens — for scripting without a browser A long-lived `finput_sk_...` token, minted at `POST /api/v1/api-tokens`, goes in the same header. The secret is shown exactly once, when the token is created. A token's scope is about *effect*, not HTTP method: `read` covers every `GET` plus the five computation endpoints, and `read_write` adds the endpoints that mutate. A token is always bounded by the intersection with its owner's own permissions, and privilege-sensitive endpoints — passwords, user management, billing changes, token management, connection management — refuse token auth outright whatever the scope. See the [API Tokens](/api/api-tokens) endpoints for the full scope rules. > JWT sessions with an idle timeout and a hard cap, rotating refresh, and long-lived API tokens