> ## Documentation Index
> Fetch the complete documentation index at: https://docs.trellis.sh/llms.txt
> Use this file to discover all available pages before exploring further.

# Authentication

> API key to JWT, in two steps.

The Trellis API uses a two-step authentication flow:

1. Exchange your **API key** for a **JWT token**.
2. Use the JWT token to authenticate every subsequent request.

The JWT is valid for six hours; refresh by re-exchanging your API key when it expires.

## Obtaining an API key

API keys are provisioned through the Trellis admin dashboard. Contact your organization administrator or reach out to Trellis support to obtain one.

<Warning>
  Keep your API key secure. Do not expose it in client-side code or commit it to version control.
</Warning>

## Computing the user\_key

Every call to `/v1/authenticate` must include a `user_key` alongside the email. The `user_key` proves the request was authorized by your backend rather than forged client-side — any party with your API key can claim an email, so we require an HMAC-derived value that only your backend can produce.

```
user_key = base64url_nopad( HMAC-SHA256( shared_secret, normalize(email) ) )

normalize(email) = email.strip().lower()
```

The output is exactly **43 ASCII characters** from the set `[A-Za-z0-9_-]`. Base64url-encoded **without** padding (strip trailing `=`). HMAC is over the raw bytes of the **normalized** email (lowercased, whitespace-trimmed), UTF-8 encoded.

<Info>
  **The `user_key` is a signature, not an identifier.** Trellis recomputes `HMAC(secret, email)` server-side using the email from the request and compares it to the submitted `user_key`. A `user_key` is bound to one specific email — submitting one user's email with another user's `user_key` always returns `401 Authentication failed`, regardless of whether both users are provisioned. The only way to authenticate as a given user is to present their specific `(email, user_key)` pair.
</Info>

<Warning>
  The shared HMAC secret must stay on your backend. Never embed it in mobile apps, single-page apps, or any client-side code. Authenticate the user with your own login flow first, then compute `user_key` server-side and hand the `(email, user_key)` pair to the client to forward to Trellis.
</Warning>

<CodeGroup>
  ```python Python theme={"theme":{"light":"github-light","dark":"github-dark"}}
  import base64, hashlib, hmac

  def compute_user_key(email: str, shared_secret: str) -> str:
      normalized = email.strip().lower()
      secret_bytes = base64.urlsafe_b64decode(
          shared_secret + "=" * (-len(shared_secret) % 4)
      )
      tag = hmac.new(secret_bytes, normalized.encode("utf-8"), hashlib.sha256).digest()
      return base64.urlsafe_b64encode(tag).rstrip(b"=").decode("ascii")
  ```

  ```javascript JavaScript theme={"theme":{"light":"github-light","dark":"github-dark"}}
  const crypto = require("crypto");

  function computeUserKey(email, sharedSecret) {
    const normalized = email.trim().toLowerCase();
    const secretBytes = Buffer.from(sharedSecret, "base64url");
    return crypto
      .createHmac("sha256", secretBytes)
      .update(normalized, "utf8")
      .digest("base64url"); // already unpadded
  }
  ```
</CodeGroup>

To obtain the shared HMAC secret for your organization, contact Trellis support. Rotating the secret invalidates every outstanding `user_key`, so we coordinate timing with you.

<Note>
  Use the same normalization on both sides. The HMAC is computed over `email.strip().lower()`. If your normalization differs from Trellis's, the HMAC won't verify and the request will return `401`. Send the email in the JSON body in its normalized form too — Trellis re-normalizes on its side, but matching ahead of time avoids edge cases with mixed-case domains.
</Note>

## Authentication flow

```mermaid theme={"theme":{"light":"github-light","dark":"github-dark"}}
sequenceDiagram
    participant App as Your Application
    participant API as Trellis API
    
    App->>API: POST /v1/authenticate<br/>X-API-Key: your_key<br/>{"email": "...", "user_key": "..."}
    API-->>App: {"token": "eyJ...", "expires_at": "..."}
    
    Note over App,API: Use JWT for all subsequent requests
    
    App->>API: GET /v1/integrations<br/>Authorization: Bearer eyJ...
    API-->>App: {"integrations": [...]}
```

## Step 1: Exchange API key for JWT

To start a session, send your API key, the user's email address, and the computed `user_key` to the authenticate endpoint.

<Tabs>
  <Tab title="cURL">
    ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}}
    curl -X POST https://api.trellis.sh/v1/authenticate \
      -H "X-API-Key: your_api_key_here" \
      -H "Content-Type: application/json" \
      -d '{"email": "user@example.com", "user_key": "your_computed_user_key"}'
    ```
  </Tab>

  <Tab title="Python">
    ```python theme={"theme":{"light":"github-light","dark":"github-dark"}}
    import requests

    response = requests.post(
        "https://api.trellis.sh/v1/authenticate",
        headers={"X-API-Key": "your_api_key_here"},
        json={
            "email": "user@example.com",
            "user_key": compute_user_key("user@example.com", shared_secret),
        },
    )

    data = response.json()
    token = data["token"]
    ```
  </Tab>

  <Tab title="JavaScript">
    ```javascript theme={"theme":{"light":"github-light","dark":"github-dark"}}
    const response = await fetch("https://api.trellis.sh/v1/authenticate", {
      method: "POST",
      headers: {
        "X-API-Key": "your_api_key_here",
        "Content-Type": "application/json",
      },
      body: JSON.stringify({
        email: "user@example.com",
        user_key: computeUserKey("user@example.com", sharedSecret),
      }),
    });

    const { token } = await response.json();
    ```
  </Tab>
</Tabs>

**Response:**

```json theme={"theme":{"light":"github-light","dark":"github-dark"}}
{
  "token": "eyJhbGciOiJIUzI1NiIs...",
  "member_id": "550e8400-e29b-41d4-a716-446655440000",
  "organization_id": "org_abc123",
  "expires_at": "2025-01-11T18:00:00Z"
}
```

<Warning>
  Users must be pre-provisioned by Trellis before they can authenticate. Auto-creation on first login was removed. To add a new user, contact your Trellis administrator with the email address you want enabled — authenticating with an unknown or disabled email returns `401 Authentication failed`.
</Warning>

## Step 2: Use the JWT token

Include the JWT token in the `Authorization` header for all API requests:

```
Authorization: Bearer eyJhbGciOiJIUzI1NiIs...
```

## Token expiration

JWT tokens expire **6 hours** after they are issued. The `expires_at` field in the authentication response tells you exactly when.

When a token expires, you'll receive a `401 Unauthorized` response. Simply re-authenticate to get a new token.

<Tip>
  For long-running applications, implement token refresh logic that re-authenticates before the token expires.
</Tip>

## Token identity (`jti`) and rate-limit buckets

Every JWT issued by `/v1/authenticate` carries a unique `jti` (JSON Web Token ID) claim — a v4 UUID generated at sign time. Trellis uses `jti` as the bucket key for [rate limits](/api-reference/chats/overview#rate-limits) and the WebSocket [concurrent-connection cap](/api-reference/chats/websocket#concurrent-connections).

Practical implications:

* Re-authenticating returns a fresh `jti`, so a new token resets every per-token throttle and concurrency slot.
* Two services sharing one JWT also share its rate-limit and connection budgets. Issue separate tokens to separate them.
* The `jti` is opaque — clients don't need to inspect or echo it; just keep using the `token` string.

## Error responses

All authentication failures return a single generic `401 Authentication failed` so the specific failure mode isn't disclosed to attackers. Treat them all as "credentials rejected — prompt the user to retry login."

| Status | Body                                                                                  | When                                                                                                                |
| ------ | ------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- |
| 400    | `{"detail": "Invalid email format"}`                                                  | The email address is structurally invalid (missing `@`, over 320 chars).                                            |
| 401    | `{"detail": "Authentication failed"}`                                                 | Missing or invalid API key, unknown email, disabled user, wrong `user_key`, or cross-tenant misuse.                 |
| 422    | Pydantic validation error with field detail                                           | `user_key` is missing or doesn't match `^[A-Za-z0-9_-]{43}$`.                                                       |
| 429    | `{"code": "RATE_LIMIT", "retry_after_seconds": N}` plus a `Retry-After: N` header     | Per-API-key rate limit on `/v1/authenticate` exceeded (60 requests per minute). Respect `Retry-After` and back off. |
| 500    | `{"detail": "JWT signing not configured"}` or `{"detail": "Server misconfiguration"}` | Trellis-side configuration error. Contact support.                                                                  |

<Tip>
  A `401` against a known-good email usually means a normalization mismatch when computing `user_key`. Verify you're lowercasing and trimming the email before HMAC, and that you're feeding raw bytes (not the base64 string itself) of the decoded shared secret to the HMAC function.
</Tip>
