---
title: Authentication
description: Token lifetimes, the AES-256-GCM envelope over each JWT, mandatory refresh-token rotation and its theft response, and the OAuth code exchange.
---

TALA issues a short-lived access JWT and a longer-lived server-side refresh token. Both are wrapped in an AES-256-GCM envelope before they leave the server, so the value a client holds is not a readable JWT.

## Lifetimes

| Credential | Lifetime | Stored at rest as |
| --- | --- | --- |
| Access token | **15 minutes** | not stored |
| Refresh token | **12 hours** | sha256 hash in `refresh_tokens`, keyed by `jti` |
| OAuth exchange code | **60 seconds**, single use | sha256 hash in `oauth_exchange_codes` |
| Password reset token | **30 minutes** | sha256 hash in `password_reset` |
| Email verification token | **24 hours** | plain token in `email_verification` |

Every one of those windows is enforced server-side. Expiry embedded in a JWT is not the only check.

## The envelope

Tokens are signed as HS256 JWTs, then encrypted with AES-256-GCM using `BUFFER_KEY` (exactly 64 hex characters) before being returned. `AuthGuard` reverses that on every request: decrypt, then verify with `algorithms: ['HS256']` **pinned** so a forged `alg: none` header cannot bypass verification.

The guard also rejects any account whose `accountStatus` is not `VERIFIED`, and strips `password` from `req.user` before handing it to a controller.

:::note[Two secrets, two jobs]
`SECRET_KEY` signs the JWT. `BUFFER_KEY` encrypts the envelope. Rotating either invalidates every outstanding token, and the app validates both at boot — it will not start with a malformed `BUFFER_KEY`.
:::

## Login

```http
POST /api/auth/login
Content-Type: application/json

{ "email": "you@example.com", "password": "…" }
```

```json title="POST /api/auth/login response"
{
  "message": "You are logged-in successfully",
  "data": {
    "user": { "id": "USa7Bk9x2Q", "email": "you@example.com" },
    "token": {
      "accessToken": "<encrypted access token>",
      "refreshToken": "<encrypted refresh token>"
    }
  }
}
```

:::danger[`data.token` is an object here and a string on refresh]
Login and [OAuth exchange](#oauth) nest the pair under `data.token`, so the access token is at `data.token.accessToken`. [Refresh](#refresh) puts the access token directly in `data.token` and the refresh token beside it. Reading `data.token` as a string after a login hands you an object; reading `data.token.accessToken` after a refresh hands you `undefined`. Neither fails loudly.
:::

A `PENDING` or `SUSPENDED` account is rejected here, not at first use.

| Outcome | Status | Message |
| --- | --- | --- |
| Wrong email or password | **400** | `Invalid email and or password` |
| Account is `PENDING` | **400** | `Please verify your email before logging in` |
| Account is `SUSPENDED` | **400** | `Your account has been suspended` |

All three are 400 by design, not 401 — see [Conventions](/api/conventions#errors). The distinct `PENDING` and `SUSPENDED` messages are only ever returned **after** a correct password, so they are not an account-enumeration oracle. Surface them to the user verbatim; the generic one is deliberately vague and should stay that way.

## Making requests

```http
GET /api/collections?scope=organization
Authorization: Bearer <encrypted_token>
x-organization-id: ORa7Bk9x2Q
```

`x-organization-id` is optional — see [organization scoping](/api/organizations).

## Refresh

```http
POST /api/auth/refresh-token
Content-Type: application/json

{ "refreshToken": "<encrypted_refresh_token>" }
```

```json title="POST /api/auth/refresh-token response"
{
  "data": {
    "token": "<new access token>",
    "refreshToken": "<rotated refresh token>"
  }
}
```

Note the missing `message`, and note that `data.token` is a **string** here — unlike login and OAuth exchange, where it is an object. `GET /api/auth/user` also omits `message`; the two are the only endpoints that do. See [Conventions](/api/conventions#the-response-envelope).

### Rotation is mandatory

The presented refresh token is **revoked** and a new one issued. The old value is dead the instant the response is written.

:::danger[Replaying a revoked refresh token revokes every session for that user]
The backend treats a replay as evidence of theft. It does not just reject the call — it revokes every outstanding refresh token the user has, logging them out everywhere.

A client that fails to persist the rotated token will therefore log the user out of every device on its second refresh. This is not a bug to work around; it is the detection mechanism.
:::

An expired, unknown, or revoked refresh token returns **401 `Access denied`** — the same status and message in all three cases, so the client cannot tell them apart and should not try. Treat any 401 from this endpoint as a dead session: clear both tokens and route to `/login`. That is what `fetchJSON` does.

Two client obligations follow:

1. **Persist the rotated token.** Always through `setTokens()`, never by writing `localStorage` directly.
2. **Single-flight concurrent refreshes.** Several requests can 401 at once; only one may call `POST /api/auth/refresh-token`. The others await the same promise.

The SPA does both:

```ts title="frontend/src/utils/api.ts"
let refreshInFlight: Promise<string | null> | null = null;

async function refreshAccessToken(): Promise<string | null> {
  const refreshToken = getRefreshToken();
  if (!refreshToken) return null;

  if (!refreshInFlight) {
    refreshInFlight = (async () => {
      /* … */
      setTokens(newAccessToken, rotatedRefreshToken);
      return newAccessToken;
    })();
  }

  return refreshInFlight;
}
```

## Logout

```http
POST /api/auth/logout
Content-Type: application/json

{ "refreshToken": "<encrypted_refresh_token>" }
```

Revokes the session server-side and returns 200 with `{ message: "You have been logged out" }`. A bad or expired token is swallowed rather than rejected, so the client can treat logout as best-effort and never block the UI on the network:

```ts title="frontend/src/utils/api.ts"
export async function serverLogout(refreshToken: string | null): Promise<void> {
  if (!refreshToken) return;
  try {
    await fetch(`${API_BASE_URL}/api/auth/logout`, { /* … */ });
  } catch {
    // Swallow — the client-side clear below is what ends the UI session.
  }
}
```

## OAuth

Google and GitHub both follow the same three-leg flow.

1. **Start**

    The SPA sends the browser to the provider entry point. There is no XHR here — it is a full navigation.

    ```ts title="frontend/src/pages/LoginPage.tsx"
    onClick={() => { window.location.href = `${API_BASE_URL}/api/auth/google` }}
    ```

2. **Callback**

    The provider returns to `/api/auth/{google,github}/callback`. The backend mints a **single-use code**, stores its sha256 hash with a 60-second expiry, and redirects to the SPA:

    ```text
    /loading?code=<single-use>&is_new=<bool>
    ```

    No tokens travel in the URL. `helmet`'s `no-referrer` policy keeps even the code out of the `Referer` header.

3. **Exchange**

    The SPA trades the code for real tokens over `POST`, stores them, and navigates.

    ```ts title="frontend/src/pages/LoadingPage.tsx"
    const code = params.get("code");
    // Strip the query either way so a reload cannot replay the code.
    window.history.replaceState({}, document.title, window.location.pathname);
    // …
    const response = await fetchJSON<OAuthExchangeResponse>(
      `${API_BASE_URL}/api/auth/oauth/exchange`,
      { method: "POST", body: JSON.stringify({ code }) },
    );
    setTokens(response.data?.token?.accessToken, response.data?.token?.refreshToken);
    await reload();
    navigate("/home", { replace: true });
    ```

    :::note[`is_new` is sent but ignored]
    The callback appends `&is_new=<bool>` and the SPA never reads it — `LoadingPage` navigates to `/home` for new and returning users alike. If onboarding should ever diverge, the flag is already there; nothing consumes it today.
    :::

### When the exchange fails

An expired code (older than 60 seconds), an already-used code, or a tampered code all return **401 `Invalid or expired code`** — again indistinguishable, again deliberately. There is no retry: the code is consumed on first use. Recovery is to start the flow over by navigating to the provider entry point.

The response carries both tokens in the same shape as [login](#login) — `data.token.accessToken` and `data.token.refreshToken`, nested, not flat. Store them through `setTokens()`, never by writing `localStorage` directly.

### Account linking requires a verified email

Linking an OAuth login to an existing local account only happens when the provider asserts the email is verified. `OAuthProfile` carries that assertion explicitly.

Without the check, an attacker could register an unverified provider email colliding with a real user and seize the account.

:::success[This flow works end to end]
`CLAUDE.md` describes OAuth as broken because the SPA read `?access_token` the backend had stopped sending. That is fixed — the buttons navigate, `LoadingPage` reads `?code`, and the exchange happens over POST.
:::

## Where tokens live on the client

`localStorage`, under `tala_token` and `tala_refresh_token` (plus `tala_organization_id` for the active org).

:::warning[XSS-exfiltratable]
Any script running on the origin can read them. The AES-over-JWT wrapper does not help here — the client holds the wrapped value, so exfiltrating it is enough. This is the main argument for the Better Auth migration and its httpOnly cookies.
:::
