---
title: Auth
description: Gate the Api with any session, without teaching the model who answered.
---

paykit does not own authentication. `Session` and `Admin` are middleware over a
single question: **who is calling, and for which account?** A deployment answers
that from its own session — Better Auth, yielded/auth, a cookie, a bearer token —
and the catalog and the model never learn which one.

## One resolver, two guards

```ts
import { Paykit } from "@potion.run/paykit";
import * as Effect from "effect/Effect";

const resolve: Paykit.ActorResolver = ({ headers }) =>
  sessionFor(headers).pipe(
    Effect.map((session) =>
      session === null ? null : Paykit.actorAccount(session.user.id),
    ),
  );

const Tenant = Paykit.SessionFrom(resolve);
const Operator = Paykit.AdminFrom(resolve);
// or both from one resolver, for a Worker that serves both surfaces
const Guards = Paykit.GuardsFrom(resolve);
```

The resolver returns `ActorValue | null`:

| Answer | The guard does |
| --- | --- |
| `Paykit.actorAccount(id)` | Provides the `Actor`; a command may target only `id`. |
| `Paykit.actorAdmin(id)` | Provides the `Actor`; a command may target any account. |
| `null` | Refuses with `Unauthenticated`. |
| A failure | Becomes a defect. A broken session store is not an anonymous caller, and must not sign a valid user out. |

`SessionFrom` reads the command's `target` and refuses with `Forbidden` when an
`account` actor names another account. `AdminFrom` requires `kind: "admin"`.
The views re-check the key, so a read is scoped even if a route forgot to be.

## Better Auth

The [auth example](https://github.com/potion-run/paykit/tree/main/examples/auth)
wires Better Auth to D1 and reduces it to one resolver:

```ts
import { BetterAuth } from "@alchemy.run/better-auth";

const auth = yield* BetterAuth({
  basePath: "/api/auth",
  emailAndPassword: { enabled: true },
});

const resolve: Paykit.ActorResolver = ({ headers }) =>
  auth.getSession(toWebHeaders(headers)).pipe(
    Effect.map((session) =>
      session === null ? null : Paykit.actorAccount(session.user.id),
    ),
  );

const Guards = Paykit.GuardsFrom(resolve);
```

Mount Better Auth's own routes outside the payment guards:

```ts
if (path.startsWith("/api/auth")) return yield* auth.fetch;
if (path.startsWith("/api")) return yield* tenant.fetch;
if (path.startsWith("/admin")) return yield* admin.fetch;
```

A caller cannot have a session before it can sign in, so `/api/auth/*` must not
require one.

## yielded/auth or your own session

The resolver API is not specific to Better Auth. A yielded/auth session, a
same-origin cookie or a signed header is the same shape:

```ts
const Guards = Paykit.GuardsFrom(({ headers }) =>
  Effect.sync(() => {
    const session = readCookie(headers);
    return session === null ? null : Paykit.actorAccount(session.accountId);
  }),
);
```

`examples/admin` uses exactly that with a cookie, and `InsecureSessionFromHeaders`
is the development implementation over `x-paykit-actor` / `x-paykit-kind` — the
name is the warning. They differ only in how they answer "who is this?".

## Where the account comes from

Choose one identity and use it everywhere:

- A user is one account: `Paykit.actorAccount(user.id)`.
- A team owns the subscription: map the session's active organization or team
  id, and the same user acts for that account.

Never derive the account from a query parameter or a request body. The guard
compares the command's target with the session's account, so a caller cannot
name another account — but the route must still pass the session's id when it
binds `Paykit.Subscribe(account)` or `Paykit.TrackUsage(account)`.

## What the deployment must preserve

A session is a cookie over state the deployment owns. Four things must survive a
deploy; lose one and a deploy signs everyone out or treats the database as new:

- **The signing secret.** `BetterAuth({ secret })` signs every session cookie.
  Alchemy provisions a stable secret by default and binds it into the host
  environment; when you pass your own, keep it across deploys. Rotating it is a
  deliberate sign-out of every user, not a routine change.
- **The stack state.** State records the auth database, the secret resource, the
  migration fingerprints and the vendor objects. Losing it makes the next deploy
  create new resources beside the old ones and run migrations against what looks
  like a new target. The examples use `Alchemy.localState()`; a deployment uses a
  durable backend.
- **The migration history.** Better Auth's migrator and paykit's are both
  additive and forward-only. Never edit a file a database already applied; the
  registry records its hash and refuses the rewrite. Migrations run at apply,
  never in the deployed Worker, so a broken migration fails the deploy rather
  than a request.
- **D1's missing interactive transactions.** D1 offers one `batch`, not
  `BEGIN … COMMIT`. Sessions fit; a plugin that needs interactive transactions
  and the billing store belong on Postgres.

paykit's side of that — the database port, the target layers and the converge
step — is in [Database](/hosting/database/).
