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

# Service accounts

> Give your application's end users short-lived, role-scoped Airhouse credentials without storing a password per user.

If you build an application on top of Airhouse, you usually don't want to create and store a long-lived database password for each of your users. A **service account** solves this: your backend holds one service-account key and uses it to mint **short-lived credentials** for whichever user is signed in to your application. The user's client then connects to Airhouse directly with those credentials. Your backend never proxies SQL.

```text theme={null}
 Your user signs in to your app
              |
              v
 Your backend --(service-account bearer)--> POST /admin/v1/tenants/{db}/tokens
              |                                   |
              |  <-- { username, password, expires_at }
              v
 Your user's client --(Postgres wire protocol, port 5445)--> Airhouse
              runs SQL with the role you minted
```

Each credential carries its own role, so Airhouse enforces `reader` / `writer` / `admin` for every end user. Each credential also has a unique username that Airhouse stores with the `subject` you supplied, so queries can be traced back to one of your users.

## When to use service accounts

Use a service account when:

* Your users sign in to your application, not directly to Airhouse.
* Each user's queries should run with that user's own role.
* You don't want to manage a long-lived Airhouse password per user.

For people, BI tools, and long-running services, create regular [database users](./managing-databases#users) instead. Both kinds of credentials can exist in the same database.

## Create a service account

Service accounts are created with the Admin API using the operator's admin token. Each service account is bound to exactly one database and has two caps: the highest role it may mint (`max_role`) and the longest lifetime it may mint (`max_ttl_secs`, at most 86,400 seconds).

```bash theme={null}
curl -X POST https://airhouse.example.com:8080/admin/v1/service-accounts \
  -H "Authorization: Bearer $AIRHOUSE_ADMIN_TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{
    "name": "billing-app-prod",
    "tenant_id": "acme",
    "max_role": "writer",
    "max_ttl_secs": 3600
  }'
```

The response includes the service account's `bearer` key:

```json theme={null}
{
  "id": "sa_8f2a8c10",
  "name": "billing-app-prod",
  "tenant_id": "acme",
  "max_role": "writer",
  "max_ttl_secs": 3600,
  "created_at": "2026-05-05T12:00:00Z",
  "revoked_at": null,
  "last_used_at": null,
  "bearer": "ahsa_..."
}
```

<Warning>
  The `bearer` is returned only once. Store it in your secret manager immediately. It's never shown again, and listing service accounts doesn't include it. To replace a lost bearer, create a new service account and revoke the old one.
</Warning>

A service account can't connect to the database itself. It can only mint credentials.

## Mint a credential

When a user signs in to your application, mint a credential for them with the service-account bearer:

```bash theme={null}
curl -X POST https://airhouse.example.com:8080/admin/v1/tenants/acme/tokens \
  -H "Authorization: Bearer $AIRHOUSE_SA_BEARER" \
  -H 'Content-Type: application/json' \
  -d '{"subject": "usr_01HK7XYZ", "role": "reader", "ttl_secs": 900}'
```

| Field | Required | Description |
| - | - | - |
| `subject` | Yes | Your stable identifier for the end user. It's stored in plain text with the credential, so use an opaque ID rather than an email address or name. |
| `role` | Yes | `reader`, `writer`, or `admin`. Can't exceed the service account's `max_role`. |
| `ttl_secs` | Yes | Lifetime in seconds. Must be greater than 0 and no more than the service account's `max_ttl_secs`. |
| `write_schemas` | No | Restricts a `writer` credential to writing in these schemas. See [Schema-scoped writers](#schema-scoped-writers). |

The response is a ready-to-use Postgres credential:

```json theme={null}
{
  "username": "eph_a1b2c3d4ef98",
  "password": "tk_...",
  "tenant": "acme",
  "role": "reader",
  "expires_at": "2026-05-05T12:15:00Z",
  "service_account_id": "sa_8f2a8c10"
}
```

Connect with `dbname` set to `tenant`, and `user` and `password` from the response:

```bash theme={null}
psql "host=airhouse.example.com port=5445 dbname=acme user=eph_a1b2c3d4ef98 password=tk_..."
```

After `expires_at`, the credential stops authenticating with SQLSTATE `28P01`, the same error as a wrong password. Mint once per session, not once per query: a credential can run any number of queries until it expires.

## Example: Python backend

```python theme={null}
import os

import psycopg2
import requests

AIRHOUSE_HTTP = os.environ["AIRHOUSE_HTTP"]   # for example https://airhouse.example.com:8080
SA_BEARER = os.environ["AIRHOUSE_SA_BEARER"]
DATABASE = "acme"


def airhouse_connection_for(subject: str, role: str = "reader", ttl_secs: int = 900):
    resp = requests.post(
        f"{AIRHOUSE_HTTP}/admin/v1/tenants/{DATABASE}/tokens",
        headers={"Authorization": f"Bearer {SA_BEARER}"},
        json={"subject": subject, "role": role, "ttl_secs": ttl_secs},
        timeout=5,
    )
    resp.raise_for_status()
    creds = resp.json()
    conn = psycopg2.connect(
        host="airhouse.example.com",
        port=5445,
        dbname=creds["tenant"],
        user=creds["username"],
        password=creds["password"],
    )
    conn.set_session(autocommit=True)
    return conn


with airhouse_connection_for(subject="usr_01HK7XYZ") as conn:
    with conn.cursor() as cur:
        cur.execute("SELECT count(*) FROM lake.main.events")
        print(cur.fetchone()[0])
```

## Example: Node.js backend

```javascript theme={null}
import { Client } from "pg";

const AIRHOUSE_HTTP = process.env.AIRHOUSE_HTTP;
const SA_BEARER = process.env.AIRHOUSE_SA_BEARER;
const DATABASE = "acme";

async function airhouseConnectionFor(subject, role = "reader", ttlSecs = 900) {
  const resp = await fetch(`${AIRHOUSE_HTTP}/admin/v1/tenants/${DATABASE}/tokens`, {
    method: "POST",
    headers: {
      Authorization: `Bearer ${SA_BEARER}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({ subject, role, ttl_secs: ttlSecs }),
  });
  if (!resp.ok) throw new Error(`mint failed: ${resp.status} ${await resp.text()}`);
  const creds = await resp.json();

  const client = new Client({
    host: "airhouse.example.com",
    port: 5445,
    database: creds.tenant,
    user: creds.username,
    password: creds.password,
  });
  await client.connect();
  return client;
}
```

## Schema-scoped writers

If several applications share one database, you can mint a `writer` that may write **only inside specific schemas**, typically the application's own schema. It can still read everything, like any writer:

```bash theme={null}
curl -X POST https://airhouse.example.com:8080/admin/v1/tenants/acme/tokens \
  -H "Authorization: Bearer $AIRHOUSE_SA_BEARER" \
  -H 'Content-Type: application/json' \
  -d '{
    "subject": "app:store-ops",
    "role": "writer",
    "ttl_secs": 900,
    "write_schemas": ["app_store_ops"]
  }'
```

The response echoes `"write_schemas": ["app_store_ops"]`. Treat a response without a matching echo as a failure: don't hand out the credential, and revoke it.

Rules for `write_schemas`:

* Only allowed with `"role": "writer"`.
* One to 64 entries, each a lowercase identifier (letters, digits, and underscores, starting with a letter or underscore, at most 63 characters), with no duplicates.
* `memory`, `system`, `temp`, and names starting with `__ducklake_metadata_` aren't allowed.

What a scoped writer can do:

* **Read** any table in the database.
* **Write** with `INSERT`, `UPDATE`, `DELETE`, `MERGE`, `TRUNCATE`, and `COPY ... FROM STDIN`, but only to tables named as `schema.table` (or `lake.schema.table`) in an allowed schema. Unqualified table names are refused.
* **Run DDL** inside its schemas: `CREATE SCHEMA` for an allowed schema, and `CREATE`, `ALTER`, and `DROP` for tables and views in allowed schemas. Unlike an unscoped writer, a scoped writer can create its own tables.
* Use `BEGIN`, `COMMIT`, and `ROLLBACK`, and the `SET` statements drivers send while connecting.

Everything else is refused, including `ATTACH`, `USE`, `SET search_path`, `INSTALL`, `LOAD`, `DROP SCHEMA`, `CREATE TEMP ...`, table functions other than `range`, `generate_series`, and `unnest`, multiple statements in one query, and SQL syntax the scope checker can't parse (for example time travel with `AT (VERSION => n)`). A refusal is SQLSTATE `42501`:

```text theme={null}
write to "other.t" refused: this credential may write only in schemas [app_store_ops]
```

To check whether a server supports scoped writers before you rely on them, call `GET /admin/v1/capabilities` with your bearer. It returns `{"mint.write_schemas": true}` on servers that support the feature.

## Revoke credentials

Revoke one minted credential early, for example when a user signs out:

```bash theme={null}
curl -X DELETE https://airhouse.example.com:8080/admin/v1/tenants/acme/tokens/eph_a1b2c3d4ef98 \
  -H "Authorization: Bearer $AIRHOUSE_SA_BEARER"
```

The next login with that credential fails. Connections that are already open aren't terminated. A service account can only revoke credentials it minted. The admin token can revoke any credential.

Revoke a whole service account with the admin token:

```bash theme={null}
curl -X DELETE https://airhouse.example.com:8080/admin/v1/service-accounts/sa_8f2a8c10 \
  -H "Authorization: Bearer $AIRHOUSE_ADMIN_TOKEN"
```

Its bearer stops working immediately. Credentials it already minted keep working until they expire, unless you revoke them individually.

## Rate limits

Each service account can mint 60 credentials per minute by default. Past the limit, the mint endpoint returns `429 Too Many Requests`. Operators can change the limit with `mint_rate_limit_per_min` (see the [configuration reference](../reference/configuration#control-plane)).

## Errors

| Status | Meaning |
| - | - |
| `400` | Invalid `role`, `ttl_secs`, `subject`, or `write_schemas` |
| `401` | Missing, unknown, or revoked bearer |
| `403` | The bearer belongs to a different database, or the requested `role` or `ttl_secs` exceeds the service account's caps |
| `404` | The database doesn't exist |
| `429` | Mint rate limit exceeded |

For the full list of endpoints, see the [Admin API reference](../reference/admin-api).


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.