# Managed auth for Relyt One, now in preview

Sign-up, sign-in, sessions and organizations for your app's users, running next to your branch and storing users in your own database. Tokens are short-lived EdDSA JWTs you verify with JWKS, and row-level security does the rest.

**Managed auth is now in preview on Relyt One. Turn it on for a branch and that branch gets its own sign-in service for your app's users: email and password, GitHub and Google, sessions, organizations with roles and permissions, and signing keys you can rotate. Users live in a `relyt_auth` schema inside the branch's own database, your backend verifies short-lived JWTs against a JWKS URL, and Postgres row-level security keeps one user's rows away from another's.**

It takes about a minute to enable, and the open-source [`@relyt/auth`](https://github.com/relytcloud/relyt-auth-js) SDK and a complete [multi-user example](https://github.com/relytcloud/relyt-auth-examples/tree/main/todo) are on GitHub. If you want to jump straight in, the step-by-step guide is [Getting started with Relyt One managed auth](/blog/managed-auth-getting-started).

This post is about why we built it this way.

## Auth is the first thing every app needs, and the first thing to drift

The moment an app stores anything personal, it needs three things: a way for users to sign in, a way for the backend to know who is calling, and a guarantee that one user never sees another user's data.

The usual way to get there is to pick an auth provider, connect it to your app, and then keep two systems in agreement for the rest of the app's life. That is where it gets hard.

- **Your users live somewhere else.** The auth provider has its own user table, in its own database, with its own ids. Every foreign key from your data to a user points across a system boundary. Joining "orders" to "users" means an API call or a sync job.
- **Environments don't line up.** Your database has a production copy, a staging copy and a branch per pull request. Your auth provider usually has one production tenant and maybe a test one. Preview environments end up signing in against production users, or against an empty tenant that no test fixtures match.
- **Authorization is enforced in only one place.** The provider tells your API who the caller is. Whether that caller may see a given row is checked in application code, query by query. Forget one `WHERE user_id = ...` and you have a data leak.

Coding agents make all three sharper. An agent that sets up a new app wires up auth in a hurry, against whatever tenant is configured, and checks permissions wherever it remembers to.

## Auth that lives on the branch

Managed auth starts from the same idea as the rest of Relyt One: things that belong to your data should live with your data.

![The browser signs in on the branch's auth URL, gets a short-lived JWT, calls your API, which verifies it with JWKS and sets app.user_id for row-level security in the same database where relyt_auth lives](/blog/assets/11a04d378f8fb58f.svg)

When you enable managed auth on a branch:

- **The branch gets its own auth URL.** Your users sign up and sign in there. It is a separate origin from your app: the sign-in form posts straight to it, so your backend never receives a password and never stores one.
- **Users, sessions and organizations are stored in your database**, in the `relyt_auth` schema of that branch. They are rows you can look at with SQL, next to the rows they own.
- **The branch gets a JWKS URL** that publishes the public keys for its tokens. Your backend only ever needs the public half.
- **Auth settings belong to the branch.** Each branch has its own sign-in methods, origins and keys, and they are removed when the branch or project is deleted. A child branch of an auth-enabled branch starts as a copy: it inherits the trusted origins and the email setting, and its database holds a copy of the parent's end users, while sessions and signing keys are not carried over.

The service is built on [Better Auth](https://www.better-auth.com/), so the sign-in endpoints and the session model will look familiar if you have used it.

## How a request is authenticated

The flow is the same for every app, whatever framework it uses.

1. **Sign in on the auth URL.** The browser posts email and password (or goes through GitHub or Google) to the branch's auth URL and gets back a session token.
2. **Exchange the session for a JWT.** The SDK trades the session for a JSON Web Token that lasts 15 minutes, signed with EdDSA. It caches the token and refreshes it before it expires.
3. **Call your API with the JWT.** The browser sends `Authorization: Bearer <jwt>`. It never verifies the token itself.
4. **Verify on the server.** Your API checks the signature against the branch's JWKS, plus the issuer, audience and expiry, and takes the user id from `sub`.
5. **Let Postgres enforce it.** Your API sets the user id for the transaction, and row-level security policies decide which rows that user can see.

In code, steps 4 and 5 are a few lines:

```ts
import { getClaims, RelytAuthUnavailableError } from "@relyt/auth/next";

export async function GET(req: Request) {
  let claims;
  try {
    claims = await getClaims(req, { issuer: process.env.RELYT_AUTH_ISSUER! });
  } catch (e) {
    // The keys could not be fetched: 503, so a network blip doesn't sign everyone out
    if (e instanceof RelytAuthUnavailableError) return new Response(null, { status: 503 });
    throw e;
  }
  if (!claims) return new Response(null, { status: 401 });

  const rows = await db.transaction(async (tx) => {
    await tx.execute(sql`select set_config('app.user_id', ${claims.sub}, true)`);
    return tx.select().from(notes); // only this user's notes
  });
  return Response.json(rows);
}
```

```sql
ALTER TABLE notes ENABLE ROW LEVEL SECURITY;
ALTER TABLE notes FORCE ROW LEVEL SECURITY;

CREATE POLICY own_notes ON notes FOR ALL
  USING (owner_id = current_setting('app.user_id', true))
  WITH CHECK (owner_id = current_setting('app.user_id', true));
```

The setting is local to the transaction, so a pooled connection never carries it to the next request. If your code forgets to set it, `current_setting` returns nothing and the policy matches no rows. A missing check means no access, never a leak.

## What's in the preview

### Sign-in methods

- **Email and password.**
- **GitHub and Google.** You paste your OAuth app's client ID and secret into the console; the secret is write-only and never shown again. Social sign-in across origins uses a one-time hand-off code with PKCE, so it works without third-party cookies.

### Redirects and origins

The auth URL only answers browsers from **trusted origins** you list, such as `https://app.example.com` or `http://localhost:3000`, with no wildcards. Social sign-in only redirects back to **callback URLs** registered exactly. Both lists are per branch, so a preview branch can trust `https://pr-128.example.dev` without production trusting it too.

### Tokens and keys

- **JWTs last 15 minutes** and are signed with EdDSA. The SDK only accepts EdDSA, so tokens signed with any other algorithm are rejected.
- **Signing keys rotate on demand.** After a rotation the new key signs the next token, old keys stay in the JWKS for 960 seconds (about 16 minutes) so tokens already issued still verify, and edge nodes pick up the new key set within about 30 seconds. At most three old keys are kept during that grace period, so further rotations inside it are refused until one expires. Verifiers should refetch the JWKS when they see an unknown key id; `@relyt/auth` does.
- **Sessions can be revoked** for one user or for everyone on the branch. A revoked session can no longer be refreshed or exchanged for new tokens. JWTs already issued stay valid until they expire, at most 15 minutes, so if access must stop instantly, check for it in your app too.

### Organizations, roles and permissions

Users can create organizations, and owners and admins can invite members through the auth URL's organization endpoints (the preview sends no invitation or verification emails, so an invitee must sign in with a verified email, which today means GitHub or Google). Each member has a role, and you define your own **resources and actions** in the console, for example a `todo` resource with `create`, `read`, `update` and `delete`, and grant them to roles. Built-in roles are `owner`, `admin` and `member`; you can add up to 16 custom roles and 32 custom resources.

When a user has an active organization, the JWT carries it:

```json
"o": {
  "id": "…",
  "slug": "acme",
  "role": "member",
  "perms": ["todo:create", "todo:read", "todo:update"]
}
```

Your API checks `o.perms` for the operation, and an RLS policy on `org_id` decides which team rows are visible. The permission list has to fit in the token, so the console limits the total to 64 `resource:action` pairs and 1 KiB; if it ever doesn't fit, `perms` is `null` and your API should refuse rather than guess.

### Operating it

- **Users** shows the branch's end users, whether their email is verified, their role, active sessions and last activity, with search over the loaded list.
- **Integration code** shows the environment variables and snippets for Next.js middleware, route handlers, the browser client and social sign-in, filled in with the branch's URLs.
- **Disable auth** stops sign-ins without deleting users, settings or keys. Enable it again and everything is where you left it.

## One thing to watch: BYPASSRLS

Row-level security only protects you if your app connects as a role it applies to. Database users created from the Relyt One console have `BYPASSRLS`, and Postgres silently skips every policy for them. For any app that relies on RLS, create a dedicated application role with SQL:

```sql
CREATE ROLE app_user LOGIN NOBYPASSRLS NOSUPERUSER NOCREATEROLE NOCREATEDB;
\password app_user
GRANT USAGE ON SCHEMA public TO app_user;
GRANT SELECT, INSERT, UPDATE, DELETE ON notes TO app_user;
```

`\password` is a psql command that prompts for the password, so it never lands in your shell history. Then check the role with `SELECT rolbypassrls FROM pg_roles WHERE rolname = 'app_user';`, which should return `f`. The example app's test suite has a case that shows what happens when you get this wrong.

## Preview limits

Managed auth is a preview, and some things are deliberately small or missing while we learn how people use it:

- Only organization owners and admins can enable auth and change its settings.
- Child branches don't copy social sign-in credentials, and a GitHub OAuth app can register only one callback URL, so use one OAuth app per branch.
- No emails are sent yet: no verification, password reset or invitation emails.
- Names, limits and the console layout may still change before general availability.

## Try it

1. Open a branch in the console, go to **Auth**, and choose **Enable auth**.
2. Follow [Getting started with Relyt One managed auth](/blog/managed-auth-getting-started) to add sign-in, token verification and RLS to an app.
3. Or clone the [TODO example](https://github.com/relytcloud/relyt-auth-examples/tree/main/todo), which does all of it with personal and team TODOs and a test suite.

Tell us what breaks. The preview is the time to change things.

[Try managed auth](/auth/signin?ref=blog-managed-auth-preview)
