Authentication
Set up auth, organizations, password reset, and API keys — with createAphexAuth or by wiring Better Auth yourself. The AuthProvider interface lives at the bottom for replacing it entirely.
Authentication is not built into @aphexcms/cms-core. The core package only defines an AuthProvider interface — a contract telling the CMS how to resolve sessions, validate API keys, and look up users. The implementation lives in your SvelteKit app.
That leaves you two paths, and they produce the same result:
@aphexcms/auth— the Better Auth instance, session/API-key service,AuthProvider, and Drizzle tables in one call. Start here.- Wiring it by hand — the same pieces as files you own. What the base template ships today, and what the package assembles for you.
Either way the CMS engine never imports Better Auth. It only consumes the resolved auth state your app injects via the hook, so upgrading auth doesn't mean upgrading cms-core.
Quick start
@aphexcms/auth hasn't been released yet. It lands in an upcoming release; until then the base
template wires auth by hand and that layout is still fully supported — see Wiring it by
hand. Everything else on this page (sign-up, roles, organizations, route
protection, auth shapes) applies to both paths.
Three lines of .env cover the basics:
BETTER_AUTH_SECRET=long-random-string-change-in-production
BETTER_AUTH_URL=http://localhost:5173
AUTH_TRUSTED_ORIGINS=http://localhost:5173BETTER_AUTH_SECRET signs session cookies and API key hashes. Rotating it invalidates every
session and key. Keep it long (32+ chars), random, and out of your repo.
Then assemble auth in one call:
import { createAphexAuth } from '@aphexcms/auth';
import { db, drizzleDb, dbDialect } from '$lib/server/db';
import { emailAdapter, emailConfig } from '$lib/server/email';
import { env } from '$env/dynamic/private';
import { building } from '$app/environment';
export const { auth, service, provider } = createAphexAuth({
database: db,
drizzleDb,
dialect: dbDialect,
secret: env.BETTER_AUTH_SECRET,
baseURL: env.BETTER_AUTH_URL,
trustedOrigins: env.AUTH_TRUSTED_ORIGINS?.split(','),
building,
emailAdapter,
email: emailConfig,
options: {
requireEmailVerification: env.AUTH_REQUIRE_EMAIL_VERIFICATION === 'true',
// On unless explicitly disabled — note the inverted test. Writing
// `=== 'true'` here would quietly turn the default off for everyone who
// never sets the variable.
inviteOnly: env.AUTH_INVITE_ONLY !== 'false'
}
});Hand provider to the CMS config:
import { provider } from '$lib/server/auth';
createCMSConfig({
auth: { provider }
});…and auth to the SvelteKit hook — see Hook composition.
What you get back
| Key | What it is |
|---|---|
auth | The underlying Better Auth instance. Exposed deliberately — anything Better Auth can do is reachable from here. |
service | Server-side operations: sessions, API keys, user lookups, password reset. |
provider | The AuthProvider Aphex consumes. Pass straight to createCMSConfig({ auth: { provider } }). |
Nothing is read from the environment. A package can't use SvelteKit's $env/dynamic/private,
and env lookups buried inside a dependency make misconfiguration hard to trace. Your app reads its
own env and passes values in.
Beyond the keys above, AphexAuthConfig also takes cache (a CacheAdapter backing session-cookie
caching and the verification-email throttle), socialProviders,
twoFactor, appName, and betterAuth. All are
optional.
What you still own
The package deliberately does not ship your /login, /reset-password/[token], or
/verify-email pages. Those are the parts worth customizing, and baking them in would mean every
site looked the same.
Options
options: {
requireEmailVerification: false,
inviteOnly: true
}| Option | Default | Behaviour |
|---|---|---|
requireEmailVerification | false | Send a verification email at sign-up and block sign-in until the address is confirmed. Off by default so a fresh install works without an SMTP server. |
inviteOnly | true | Restrict account creation to addresses holding a pending, unexpired invitation — except while the instance is empty, so the first sign-up can claim it. |
The two defaults are designed as a pair: the first person to sign up owns the instance, and the door shuts behind them. No configuration, no code to copy out of a log — the same install flow as WordPress, Ghost, Strapi, Payload and Dokploy, but without leaving public registration open afterwards.
That leaves exactly one window: an instance reachable before you claim it belongs to whoever
finds the URL first. Sign up right after deploying, or close the window with a
bootstrap recipe — not by turning inviteOnly off.
inviteOnly gates the sign-up endpoint, not just the form, so a direct POST is rejected the
same way. It is not Better Auth's disableSignUp: an invitee has to create an account before they
can accept an invitation, so disabling sign-up outright makes invitations impossible to accept.
The empty-instance exception requires proof of emptiness. An adapter without
hasAnyUserProfiles() falls through to the gate rather than being waved past — the inverse fails
open, which is how a missing implementation once promoted every sign-up to super admin.
Schema
The auth tables (user, session, account, verification, apikey, two_factor) ship per
dialect. Your app re-exports them from one local file:
export * from '@aphexcms/auth/schema/pg';export * from '@aphexcms/auth/schema/sqlite';Re-exporting rather than importing the package at every call site buys two things: drizzle-kit keeps
resolving a local path in your schema.ts, and you get one obvious file to edit when you want to
change a table.
The tables move with the Better Auth version that expects them, which is the reason they live in the package at all — a hand-copied duplicate is exactly how the two drift apart.
Match the dialect to the Drizzle client you actually use — pg for Postgres and PGlite, sqlite
for libsql. Mixing them produces queries that compile but fail at runtime.
Adding tables and columns
Three cases, handled differently.
Your own tables
Nothing to do with auth. Define them in your own file and export it from your schema barrel alongside the CMS and auth schemas:
export * from './cms-schema';
export * from './auth-schema';
export * from './my-schema'; An extra column on an auth table
You can't extend a Drizzle table object, so you redefine the table in your local auth-schema file
instead of re-exporting it. An explicit export wins over export *, so this overrides cleanly:
import { pgTable, text, timestamp, boolean } from 'drizzle-orm/pg-core';
export * from '@aphexcms/auth/schema/pg';
// Same table name, one extra column — this export shadows the re-exported `user`.
export const user = pgTable('user', {
id: text('id').primaryKey(),
name: text('name').notNull(),
email: text('email').notNull().unique(),
emailVerified: boolean('email_verified').default(false).notNull(),
image: text('image'),
stripeCustomerId: text('stripe_customer_id'),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull()
});pnpm db:generate then picks it up as an ordinary migration:
ALTER TABLE "user" ADD COLUMN "stripe_customer_id" text;The migration only creates the column. For Better Auth to read and write it, declare it as an additional field through the escape hatch:
betterAuth: (base) => ({
...base,
user: { additionalFields: { stripeCustomerId: { type: 'string', required: false } } }
})Tables from a Better Auth plugin
Two-factor is the exception — its tables ship with the package because it's a first-class option. Any other plugin you add through the escape hatch brings its own tables, so define them in your own schema file. Check the plugin's schema against the Better Auth version you have installed: the required fields do change between releases.
Bootstrapping the first admin
Claiming a fresh instance is a deployment question, not a library one — so it's a policy you pass in. Four recipes ship with the package, and anything they don't cover is a plain function.
import { createAphexAuth, allowlistEmail } from '@aphexcms/auth';
createAphexAuth({
// ...
bootstrap: allowlistEmail(env.APHEX_BOOTSTRAP_EMAIL)
});| Recipe | Who becomes super admin | Mirrors |
|---|---|---|
openFirstUser() (default) | Whoever signs up first, no further proof | WordPress, Ghost, Strapi, Payload, Dokploy |
claimCode() | First user who also supplies a code printed to the server log at startup | Jupyter, GitLab |
allowlistEmail(emails) | First user whose address is on the allowlist | Discourse |
never() | Nobody — provision out of band | Directus, Keycloak |
allowlistEmail is only as strong as the address is trustworthy, so pair it with
AUTH_REQUIRE_EMAIL_VERIFICATION — otherwise anyone who knows the
configured address can register as it. That's enforced at the auth layer, not by the policy:
Better Auth won't complete sign-in for an unconfirmed address, so an unverified user never reaches
profile creation. The policy logs a warning if it promotes an unverified address, but doesn't
block — that decision isn't bootstrap's to make.
Hardening the first run
The default is "first user wins", the same install flow as WordPress, Ghost, Strapi, Payload and Dokploy. It's fine when you install the moment you deploy, and it's what most people want.
It stops being fine when an instance sits reachable before anyone signs in — a container that's up for ten minutes before you open it, or one redeployed onto an empty volume. Then the first stranger to find the URL owns your CMS. If that window is real for your deployment, close it.
allowlistEmail() is the simpler answer when you know the owner's address at deploy time.
claimCode() keeps the same first-run flow and adds one step: promotion also requires a code
that only someone with access to the deployment's logs could have.
This instance has no administrator yet.
Sign up at /admin and enter this claim code to become the super admin:
Xk4mQ2pR9vLc7hT1wY6nB3sF8jD5gA0zThe sign-up form shows a Claim code field whenever the instance is unclaimed, so paste it there
and sign up — nothing else to do. The field is driven by isInstanceUnclaimed(db), which is true
only while no profile exists and a code is still pending; it reveals that the instance is empty and
nothing more.
Under the hood the form sets a short-lived aphex_bootstrap_code cookie just before sign-up, and
clears it as soon as sign-up returns. It has to be a cookie rather than a header: the policy runs
while the auth client's own sign-up request is being handled, and the form can't set a header on it.
If you're driving sign-up yourself, the code is read from an x-aphex-bootstrap-code header or that
cookie, in that order. Pass readCode to accept it from somewhere else:
A ?claim=<code> query parameter used to be accepted and no longer is. A URL carrying the code
ends up in browser history, access logs, and any Referer header sent to a third party — several
places nobody thinks to clear a credential out of. Use the header or the cookie.
claimCode({ readCode: (request) => request?.headers.get('x-setup-token') ?? undefined });Only the code's hash is stored, so a leaked database dump doesn't hand someone the instance. It's single-use — cleared the moment it's accepted.
A recipe carries its own startup step. claimCode() sets policy.prepare to the function that
generates and logs the code; every other recipe leaves it undefined. Call
bootstrapPolicy.prepare?.(db) once at boot (studio does this in hooks.server.ts) and switching
recipes needs no other change — you can't strand a hook that logs a code nothing asks for.
The claim window closes on the first sign-up, not on the code being used. Someone who signs up
without entering the code becomes an ordinary editor and leaves the code permanently
unreachable, since the policy checks "is this the first user?" before anything else. Recovering
means clearing bootstrapClaimCodeHash from instance settings and promoting the row by hand.
Single-use is not the same as mutually exclusive. Two concurrent requests can both read the code
before either clears it, so a determined race can produce two super admins — both of whom
already had the code. Preventing it needs a database-level uniqueness constraint, not a longer
lock: holding a transaction across the claim and the profile write blocks the auth provider's own
concurrent inserts, which fails sign-up outright on SQLite (SQLITE_BUSY).
Keeping the classic behavior
Nothing stops you — just say so explicitly:
import { openFirstUser } from '@aphexcms/auth';
createAphexAuth({ bootstrap: openFirstUser() });This is already the default, so you only need to write it out to be explicit. Either way it
assumes you claim the instance promptly after deploying — inviteOnly shuts the
sign-up endpoint behind you, but it can't help before anyone has signed up.
Writing your own
The policy is one function. It returns the instance role to grant, or null for no promotion —
in which case the user gets the ordinary editor role.
bootstrap: async ({ user, isFirstUser, request, db }) => {
if (!isFirstUser) return null;
return user.email.endsWith('@example.com') ? 'super_admin' : null;
};isFirstUser is true only when the system is provably empty. An adapter that can't answer
the question reports false, so "couldn't check" is never mistaken for "nobody's here yet".
Sign-up flow
The first sign-up bootstraps the system; everyone after that is opt-in via invitation.
First user signs up → assigned the super_admin instance role automatically. A default
organization is created with them as owner. They're dropped into the admin UI immediately.
Subsequent users sign up → assigned the editor instance role automatically. They have no
organization membership yet, so they hit the /invitations screen on first login. From there
they accept any pending invitation an org owner / admin sent to their email.
Editor invites teammates — owners and admins use the admin UI's members page (or POST /api/organizations/{id}/invitations) to invite by email. The invitee gets a Better Auth-signed
link, clicks through, signs up if needed, and is auto-added as a member.
Email verification & password reset
Both flows require an email adapter. The base template uses createMailpitAdapter() in dev (so verification + reset mail lands at http://localhost:8025) and createResendAdapter() in prod.
| Flow | Trigger | Token expiry |
|---|---|---|
| Email verification | New sign-up | 1 day |
| Password reset | "Forgot password" on /login | 1 hour |
If email is null in your config, both flows are disabled — sign-ups are auto-verified and password reset returns a 503.
Turning verification on
Email verification is off by default, so a fresh install works without an SMTP or Mailpit server. Opt in explicitly:
# apps/studio/.env (or your project's .env)
AUTH_REQUIRE_EMAIL_VERIFICATION=trueOnly the exact string true enables it — any other value (or omitting the variable) leaves it off. With it off, the first sign-up can log in immediately and no verification email is sent.
Turn this on in production. Without it, anyone can sign up with an address they don't own, and
since the first user to sign up becomes super admin, that's an account-takeover risk. Pair it with
inviteOnly if the site shouldn't accept public sign-ups at all.
Roles and permissions
Aphex uses a capability-based access control system. Every protected operation is gated against a capability string (document.publish, member.invite, asset.upload, …) and roles are named bundles of those capabilities. See Access Control for the full list of capabilities and the per-schema / per-field rules.
Instance (system) roles
Every CMS user has a system-wide role on their profile, independent of any organization. This is the "break glass" path.
Prop
Type
Organization roles
Each user also has a role per organization they belong to. Four built-in roles are seeded for every new organization, and you can edit their capabilities or add your own.
| Role | Capabilities |
|---|---|
owner | Every capability, plus the hardcoded ability to delete the organization. |
admin | Everything editor has, plus member.*, apiKey.manage, role.manage, and org.settings. |
editor | All document and asset capabilities (read / create / update / delete / publish / unpublish + upload / delete). |
viewer | Read-only — document.read and asset.read only. |
Built-in roles are editable and can't be deleted. Each organization can also define custom roles with any capability list — schemas can then grant access to specific role names.
Checking capabilities in code
Capabilities are resolved once per request by the auth hook (via RolesService) and attached to auth.capabilities. Checks are synchronous:
import {
hasCapability,
canWrite,
canManageMembers,
canManageApiKeys,
isViewer
} from '@aphexcms/cms-core/server';
hasCapability(auth, 'document.publish'); // exact capability check
canWrite(auth); // any mutating doc/asset capability
canManageMembers(auth); // member.invite | member.remove | member.changeRole
canManageApiKeys(auth); // apiKey.manage
isViewer(auth); // inverse of canWriteOrganizations
Aphex supports multi-tenancy with a one-level parent / child hierarchy.
Parent Organization (e.g. Record Label)
├── Child Organization A (e.g. Artist 1)
└── Child Organization B (e.g. Artist 2)- A parent organization can read documents and assets from all its children.
- A child organization can only access its own data.
- Writes are always scoped to the user's active organization — a parent can't accidentally modify child data.
Users can belong to multiple organizations and switch between them via the admin UI's organization switcher. The active one is persisted in cms_user_sessions.
Invitations
POST /api/organizations/{id}/invitations
{
"email": "[email protected]",
"role": "editor"
}The full flow:
If an email adapter is configured, an invite email is sent. (Without one, surface the link in the admin UI yourself.)
/invite/{token}.They're added as an organization member with the assigned role and dropped into the admin UI.
Route protection
The CMS hook automatically protects routes based on the auth configuration:
| Route pattern | Auth required | Auth type |
|---|---|---|
/admin/* | Yes | Session only |
/api/* | Yes | Session or API key |
/media/*, /assets/* | Optional | Session, API key, or public |
For API routes, if an x-api-key header is present, it takes precedence over session cookies. Mutating requests (POST, PUT, PATCH, DELETE) require write permission — read-only API keys receive a 403.
The one exception is POST /api/documents/query, which is treated as a read operation because the POST is only used to carry the complex filter payload.
OAuth
OAuth needs no schema change: provider links live in the account table the auth schema already
includes — providerId, accountId, accessToken, refreshToken, one row per (user, provider)
pair. If you provisioned the database with the current schema, OAuth works without a migration.
Add the provider. With createAphexAuth, pass it straight through:
createAphexAuth({
// ...
socialProviders: {
google: {
clientId: env.GOOGLE_CLIENT_ID,
clientSecret: env.GOOGLE_CLIENT_SECRET
}
}
});Wiring by hand, it's the same key on your own betterAuth({ ... }) call.
Set the env vars and make sure your auth URL is in AUTH_TRUSTED_ORIGINS:
GOOGLE_CLIENT_ID=...
GOOGLE_CLIENT_SECRET=...
BETTER_AUTH_URL=https://cms.example.com
AUTH_TRUSTED_ORIGINS=https://cms.example.comRegister the OAuth callback URL in Google Cloud Console (or the provider's dashboard): <BETTER_AUTH_URL>/api/auth/callback/google. Locally that's http://localhost:5173/api/auth/callback/google.
Add a sign-in button that calls the Better Auth client:
<script lang="ts">
import { signIn } from '$lib/auth-client';
function loginWithGoogle() {
signIn.social({ provider: 'google', callbackURL: '/admin' });
}
</script>
<button onclick={loginWithGoogle}>Sign in with Google</button>The first OAuth user becomes super_admin and gets a default organization, exactly like email sign-up. Subsequent users land on /invitations until an admin invites them.
Why OAuth and other sign-in methods "just work": Aphex uses lazy profile sync. The first time
getSession sees a user with no cms_user_profiles row, it creates one (the first user ever
becomes super_admin, everyone else becomes editor). This runs regardless of whether the user
signed in via email, OAuth, magic link, or anything else.
For other providers (GitHub, Apple, Microsoft, Discord, OIDC, …) see Better Auth — Social Sign-on.
Two-factor authentication
Off by default. Turning it on adds a TOTP second factor — the user scans a QR code with an authenticator app (1Password, Authy, Google Authenticator) and enters a 6-digit code at sign-in — plus one-time backup codes for recovery.
createAphexAuth({
// ...
appName: 'Acme CMS', // shown as the issuer in the authenticator app
twoFactor: true
});true takes the defaults; pass an object to configure it (issuer, skipVerificationOnEnable,
totpOptions, backupCodeOptions). The type is Better Auth's own TwoFactorOptions, so every
option the installed version supports is reachable.
No migration needed to turn this on. The two_factor table and user.two_factor_enabled
column ship in the schema whether or not the option is set — a config flag that silently changes
your table shape would make enabling 2FA a migration on a live install. The plugin, and the
/two-factor/* routes it mounts, are what's conditional.
Client setup
Enrollment and verification are driven from the browser, so add the client plugin:
import { createAuthClient } from 'better-auth/client';
import { twoFactorClient } from 'better-auth/client/plugins';
export const authClient = createAuthClient({
plugins: [
twoFactorClient({
onTwoFactorRedirect() {
window.location.href = '/two-factor';
}
})
]
});Once a user has 2FA enabled, signIn.email no longer returns a session — it returns
twoFactorRedirect: true and the sign-in is incomplete until the code is verified. Build the
verification screen before enabling this, or you'll lock users at the login form.
Enrolling a user
Enable it, re-authenticating with the current password. This returns the TOTP URI to render as a QR code, plus the backup codes — the only time they're shown in plaintext.
const { data } = await authClient.twoFactor.enable({ password });
// data.totpURI → render as a QR code
// data.backupCodes → show once, tell the user to store themVerify the first code. twoFactorEnabled stays false until this succeeds, so a user who scans the QR code but never confirms isn't locked out of their own account.
await authClient.twoFactor.verifyTotp({ code, trustDevice: true });trustDevice skips the prompt on that device for 30 days, refreshed on each sign-in.
At sign-in, handle the challenge:
await authClient.signIn.email(
{ email, password },
{
onSuccess(ctx) {
if (ctx.data.twoFactorRedirect) {
// send them to your /two-factor screen
}
}
}
);Disabling is authClient.twoFactor.disable({ password }), which deletes the row and clears the flag.
2FA gates the credential sign-in endpoints (/sign-in/email, /sign-in/username,
/sign-in/phone-number). It does not gate OAuth, magic links, or passkeys — those are already
second factors of a sort, and Better Auth leaves the policy to you. If a user can reach an account
through both a password and Google, enabling 2FA only covers the password path.
Other Better Auth features
These need no AphexCMS changes — configure them on the Better Auth instance, or through the escape hatch:
| Feature | Better Auth reference |
|---|---|
| OAuth providers | Social Sign-on |
| Magic link / email OTP | Magic Link, Email OTP |
| Two-factor auth | Two Factor |
| Passkeys / WebAuthn | Passkey |
| Session lifetime, cookie policy | Session Management |
| Rate limiting | Rate Limit |
| Cookie cache (perf) | Cookie Cache — already enabled in the base template with a 60s TTL |
Auth shapes
The AuthProvider returns one of three states. You'll typically read them off event.locals.auth.
SessionAuth
Full browser session with active organization context — the admin UI's normal state.
interface SessionAuth {
type: 'session';
user: CMSUser;
session: { id: string; expiresAt: Date };
organizationId: string;
organizationRole: OrganizationRole; // 'owner' | 'admin' | 'editor' | 'viewer'
organizations?: Array<{
id: string;
name: string;
slug: string;
role: OrganizationRole;
isActive: boolean;
}>;
}PartialSessionAuth
Authenticated user who hasn't joined an organization yet (just signed up, has pending invitations).
interface PartialSessionAuth {
type: 'partial_session';
user: CMSUser;
session: { id: string; expiresAt: Date };
}The CMS redirects users with this state to /invitations until they accept an invite or get added.
ApiKeyAuth
Programmatic access via the x-api-key header.
interface ApiKeyAuth {
type: 'api_key';
keyId: string;
name: string;
permissions: ('read' | 'write')[];
organizationId: string;
}API keys are always scoped to a single organization. See API Keys for capability-based keys (the modern equivalent of permissions).
Under the hood
Everything above sits on one seam: your app builds an AuthProvider and hands it to
createCMSConfig. The CMS never learns which library produced it.
┌─────────────────────────────────────────────────────┐
│ Your SvelteKit App (app-level) │
│ │
│ src/lib/server/auth/ │
│ createAphexAuth() ─or─ your own wiring │
│ → { auth, service, provider } │
│ │
│ src/hooks.server.ts │
│ sequence(authHook, aphexHook) │
└──────────────────────────────────┬──────────────────┘
│ passes AuthProvider
▼
┌─────────────────────────────────────────────────────┐
│ @aphexcms/cms-core (package-level) │
│ │
│ Defines: AuthProvider interface │
│ Consumes: event.locals.auth (SessionAuth, etc.) │
│ Never imports: better-auth, lucia, authjs, etc. │
└─────────────────────────────────────────────────────┘Which is why different apps can use different auth backends against the same CMS engine, and why tests can inject a mock provider without spinning up a real auth server.
Hook composition
Both Better Auth and the CMS run via SvelteKit's sequence():
import { sequence } from '@sveltejs/kit/hooks';
import { createCMSHook } from '@aphexcms/cms-core/server';
import { auth } from '$lib/server/auth/index.js';
import config from '../aphex.config.ts';
// Better Auth handles /api/auth/* routes
const authHook: Handle = async ({ event, resolve }) => {
return svelteKitHandler({ event, resolve, auth });
};
// CMS hook — route protection + DI on locals.aphexCMS
const aphexHook = createCMSHook(config);
export const handle = sequence(authHook, aphexHook);The auth hook must come before the CMS hook so sessions are available when the CMS checks capabilities.
You still own Better Auth
Better Auth is a peer dependency of @aphexcms/auth. You choose the version in your own
package.json and upgrade when you want — the package doesn't pin you to its release cycle.
The escape hatch
betterAuth receives the fully assembled options and returns what actually gets passed to
betterAuth(). This is what keeps the wrapper from becoming a ceiling: any option Better Auth
gains — new plugins, new providers, new session policies — works immediately, without waiting on a
release here.
import { twoFactor } from 'better-auth/plugins';
createAphexAuth({
// ...
betterAuth: (base) => ({
...base,
plugins: [...base.plugins, twoFactor()],
session: { ...base.session, expiresIn: 60 * 60 * 24 * 30 }
})
});Reach for this only when a first-class option doesn't already cover it. Spreading base matters —
replacing a key outright drops the defaults the package set up for you (cookie cache, rate limits,
the API-key plugin, CMS profile sync).
Wiring it by hand
The package is a convenience, not a requirement — and it's what the base template currently ships, as files you own:
| File | Responsibility |
|---|---|
index.ts | Exports the AuthProvider instance Aphex consumes. |
auth.config.ts | App-owned options — requireEmailVerification, inviteOnly. |
service.ts | AuthService — session, API key, lazy user-profile sync, first-user bootstrap. |
better-auth/instance.ts | Better Auth instance with email & password, verification, API keys, organizations. |
Configure Better Auth directly on instance.ts. Aphex only consumes the resolved session shape
(auth.user, auth.organizationRole), not how the user authenticated — so if a feature is
configurable on Better Auth, you don't need to touch AphexCMS to use it. See the Better Auth
documentation.
Custom auth providers
You can replace Better Auth entirely — Lucia, Auth.js, something custom — by implementing the AuthProvider interface and passing your instance to createCMSConfig({ auth: { provider } }). Most projects will never need this.
interface AuthProvider {
// Session auth (browser, admin UI)
getSession(
request: Request,
db: DatabaseAdapter
): Promise<SessionAuth | PartialSessionAuth | null>;
requireSession(request: Request, db: DatabaseAdapter): Promise<SessionAuth>;
// API key auth (programmatic access)
validateApiKey(request: Request, db: DatabaseAdapter): Promise<ApiKeyAuth | null>;
requireApiKey(
request: Request,
db: DatabaseAdapter,
permission?: 'read' | 'write'
): Promise<ApiKeyAuth>;
// User management
getUserById(
userId: string
): Promise<{ id: string; name?: string; email: string; image?: string } | null>;
getUserByEmail(
email: string
): Promise<{ id: string; name?: string; email: string; image?: string } | null>;
changeUserName(userId: string, name: string): Promise<void>;
changeUserImage?(userId: string, image: string | null): Promise<void>;
// Password reset
requestPasswordReset(email: string, redirectTo?: string): Promise<void>;
resetPassword(token: string, newPassword: string): Promise<void>;
}Your provider needs to:
- Resolve sessions — return
SessionAuthwith org context for the admin UI, orPartialSessionAuthfor users without an org. - Validate API keys — return
ApiKeyAuthscoped to one organization. - Look up users — resolve user IDs to email / name (used in version history
createdByName, etc.). - Handle password reset — token-based reset, or throw if unsupported.
Once you pass the provider to auth.provider, the rest of the system (route protection, Local API context, admin UI) works without modification.
See also
Last updated on