> For clean Markdown of any page, append .md to the page URL.
> For a complete documentation index, see https://docs.finput.com.au/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 <access_token>" \
  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 <access>`. 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": "<refresh_token>"}`. 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": "<refresh_token>"}` 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.