Aphex

Authentication

Set up auth, organizations, password reset, and API keys. The AuthProvider interface lives at the bottom for replacing Better Auth.

The base template ships with Better Auth and a full sign-up / sign-in / invitation flow already wired up. Most projects don't have to write any auth code — set the env vars, configure an email adapter, and you're done. The AuthProvider interface at the bottom of this page is only relevant if you're swapping in a different auth backend.

Quick setup

The base template covers everything below in three lines of .env:

.env
BETTER_AUTH_SECRET=long-random-string-change-in-production
BETTER_AUTH_URL=http://localhost:5173
AUTH_TRUSTED_ORIGINS=http://localhost:5173

BETTER_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.

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.

FlowTriggerToken expiry
Email verificationNew sign-up1 day
Password reset"Forgot password" on /login1 hour

If email is null in your config, both flows are disabled — sign-ups are auto-verified and password reset returns a 503.

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.

RoleCapabilities
ownerEvery capability, plus the hardcoded ability to delete the organization.
adminEverything editor has, plus member.*, apiKey.manage, role.manage, and org.settings.
editorAll document and asset capabilities (read / create / update / delete / publish / unpublish + upload / delete).
viewerRead-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 canWrite

Organizations

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:

An invitation row is created with a 7-day expiry and a one-time token.

If an email adapter is configured, an invite email is sent. (Without one, surface the link in the admin UI yourself.)

The invitee signs up (or logs in) and accepts at /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 patternAuth requiredAuth type
/admin/*YesSession only
/api/*YesSession or API key
/media/*, /assets/*OptionalSession, 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.

Better Auth setup files

The scaffolded project's auth lives in src/lib/server/auth/:

index.ts
service.ts
FileResponsibility
index.tsExports the AuthProvider instance Aphex consumes.
service.tsAuthService — session, API key, lazy user-profile sync, first-user bootstrap.
better-auth/instance.tsBetter Auth instance with email & password, verification, API keys, organizations.

Hook composition

Both Better Auth and the CMS run via SvelteKit's sequence():

src/hooks.server.ts
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.

Customizing Better Auth

Anything Better Auth supports — OAuth, magic links, passkeys, two-factor, custom cookie/session policies — is configured directly on the Better Auth instance at src/lib/server/auth/better-auth/instance.ts. AphexCMS only consumes the resolved session shape (auth.user, auth.organizationRole), not how the user authenticated. If a feature is configurable on Better Auth, you don't need to touch AphexCMS to use it.

For full configuration options, see the Better Auth documentation.

Why OAuth and other sign-in methods "just work": AphexCMS 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.

Adding OAuth (example: Google)

Add the provider to betterAuth({ ... }):

src/lib/server/auth/better-auth/instance.ts
return betterAuth({
	// ...existing config...
	socialProviders: {
		google: {
			clientId: env.GOOGLE_CLIENT_ID!,
			clientSecret: env.GOOGLE_CLIENT_SECRET!
		}
	}
});

Set the env vars and make sure your auth URL is in AUTH_TRUSTED_ORIGINS:

.env
GOOGLE_CLIENT_ID=...
GOOGLE_CLIENT_SECRET=...
AUTH_URL=https://cms.example.com
AUTH_TRUSTED_ORIGINS=https://cms.example.com

Register the OAuth callback URL in Google Cloud Console (or the provider's dashboard): <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:

src/routes/login/+page.svelte
<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>

That's it. The first OAuth user becomes super_admin and gets a default organization, exactly like the email sign-up flow. Subsequent users land on /invitations until an admin invites them to an org.

No schema changes needed. The base template's auth schema (src/lib/server/db/auth-schema.ts) already includes the account table Better Auth uses to store OAuth provider links — providerId, accountId, accessToken, refreshToken, etc. One row per (user, provider) pair. If you provisioned the database with the current schema, OAuth works without a migration. If you're adding OAuth to an older deployment, run pnpm db:push (dev) or pnpm db:generate && pnpm db:migrate (prod) to make sure the columns are present.

For other providers (GitHub, Apple, Microsoft, Discord, OIDC, …) see Better Auth — Social Sign-on.

Other Better Auth features

These all live in instance.ts and don't require any AphexCMS changes:

FeatureBetter Auth reference
OAuth providersSocial Sign-on
Magic link / email OTPMagic Link, Email OTP
Two-factor authTwo Factor
Passkeys / WebAuthnPasskey
Session lifetime, cookie policySession Management
Rate limitingRate 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).

Custom auth providers

You can replace Better Auth entirely by implementing the AuthProvider interface and passing your instance to createCMSConfig({ auth: { provider } }). Most projects will never need this — Better Auth covers email + password, OAuth, organizations, API keys, and verification out of the box.

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 } | null>;
	changeUserName(userId: string, name: string): Promise<void>;

	// Password reset
	requestPasswordReset(email: string, redirectTo?: string): Promise<void>;
	resetPassword(token: string, newPassword: string): Promise<void>;
}

Your provider needs to:

  1. Resolve sessions — return SessionAuth with org context for the admin UI, or PartialSessionAuth for users without an org.
  2. Validate API keys — return ApiKeyAuth scoped to one organization.
  3. Look up users — resolve user IDs to email / name (used in version history createdByName, etc.).
  4. 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

Edit on GitHub

Last updated on