All guides

Authentication and Roles

Set up login for CMS editors, manage team members, assign roles, and invite collaborators.

3 min read594 words

Airdraft has a built-in authentication system for CMS editors — distinct from your public website's auth. This guide covers setting up login, managing team members, and understanding roles.

Self-hosted CMS roles

Role What they can do
admin Full access — create/edit/delete entries in any collection, manage users, edit schema, manage media, view audit log
publisher Create, edit, and publish entries; manage media; cannot manage users or edit schema
editor Create and edit entries (draft only); cannot publish, manage users, or edit schema

Airdraft Cloud team roles

Cloud teams use owner, admin, editor, and viewer. The team creator is the owner. Owners can manage billing and ownership-sensitive actions; admins manage projects and members; editors create and update content; viewers have read-only access.

When Cloud authorizes a hosted CMS request, roles map to the self-hosted CMS role model: owner/admin → admin, editor → publisher, and viewer → editor.


Airdraft Cloud — team management

In the dashboard, go to Settings → Team.

Inviting a team member

  1. Click Invite member.
  2. Enter the person's email address.
  3. Select a role (admin, editor, or viewer).
  4. Click Send invite.

The invitee receives an email with a sign-up link. Once they accept, they appear in the team list.

Changing a role

  1. Find the member in the Members list.
  2. Click the role badge next to their name.
  3. Select the new role.

Changes take effect immediately.

Removing a member

  1. Find the member in the Members list.
  2. Click ⋯ → Remove from team.
  3. Confirm.

The removed member loses access immediately.


Self-hosted — authentication setup

When self-hosting, add the @airdraft/plugin-auth plugin. The CLI adds this automatically if you select "Authentication" during npx airdraft init.

Credentials (email + password)

// airdraft.config.ts
import { defineConfig, LocalAdapter } from '@airdraft/core'
import { withAutoAuth } from '@airdraft/plugin-auth'

const adapter = new LocalAdapter({ root: './content' })
const auth = withAutoAuth()  // reads AIRDRAFT_JWT_SECRET from env; returns null if not set

export default defineConfig({
  adapter,
  schemaPath: 'airdraft.schema.json',
  plugins: [
    ...(auth ? [auth] : []),
  ],
})

This adds all auth routes at /api/cms/auth/*. Users are stored in .airdraft/users.json by default.

Route Method Description
/auth/login POST Email + password login
/auth/logout POST Invalidate session
/auth/refresh POST Rotate access token
/auth/me GET Return current user
/auth/users GET List all users (admin only)
/auth/users/:id/role PATCH Change a user's role (admin only)
/auth/users/:id DELETE Remove a user (admin only)
/auth/invites GET List pending invites (admin only)
/auth/invites POST Send an invite (admin only)
/auth/invites/:token DELETE Revoke an invite (admin only)
/auth/invites/accept POST Accept an invite and set password

withAuth (explicit provider)

Use withAuth when you need to explicitly configure a provider or restrict public paths:

import { withAuth, CredentialsProvider, UserStore } from '@airdraft/plugin-auth'

const userStore = UserStore.json()  // reads/writes .airdraft/users.json

const auth = withAuth({
  provider: CredentialsProvider({
    userStore,
    secret: process.env.AIRDRAFT_JWT_SECRET!,
    roles: { 'admin@example.com': 'admin' },  // optional per-email role overrides
  }),
  publicPaths: ['/api/cms/posts'],  // bypass auth on these routes
})

Creating the first admin

After setup, create the first admin user:

npx airdraft create-user

The CLI prompts for email, password, and role.

Durable users on serverless platforms

The default JSON user store requires a persistent filesystem. For Vercel, AWS Lambda, horizontally scaled containers, or any ephemeral filesystem, use the MongoDB adapter's user store and run its migration during startup:

import { MongoAdapter } from '@airdraft/db-adapter-mongodb'
import { withAutoAuth } from '@airdraft/plugin-auth'

const adapter = new MongoAdapter({ uri: process.env.MONGODB_URI! })
const auth = withAutoAuth({ userStore: adapter.userStore() })
// instrumentation.ts
import airdraft from './airdraft.config'
import { BaseDatabaseAdapter } from '@airdraft/db-adapter'

export async function register() {
  if (airdraft.adapter instanceof BaseDatabaseAdapter) {
    await airdraft.adapter.migrate()
  }
}

npx airdraft create-user detects adapter.userStore() and writes the first admin to MongoDB instead of .airdraft/users.json.

OAuth providers

Add GitHub or Google OAuth by including the provider in the plugin config:

import { withAuth, CredentialsProvider, GitHubOAuthProvider } from '@airdraft/plugin-auth'

const auth = withAuth({
  provider: CredentialsProvider({ userStore, secret: process.env.AIRDRAFT_JWT_SECRET! }),
})

// Or use a GitHub OAuth provider:
const auth = withAuth({
  provider: GitHubOAuthProvider({
    clientId: process.env.GITHUB_CLIENT_ID!,
    clientSecret: process.env.GITHUB_CLIENT_SECRET!,
    secret: process.env.AIRDRAFT_JWT_SECRET!,
    allowedOrgs: ['my-org'],   // optional: restrict by org
    allowedUsers: ['my-user'], // optional: restrict by username
    defaultRole: 'editor',
  }),
})

// Or Google:
const auth = withAuth({
  provider: GoogleOAuthProvider({
    clientId: process.env.GOOGLE_CLIENT_ID!,
    clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
    secret: process.env.AIRDRAFT_JWT_SECRET!,
    allowedDomains: ['mycompany.com'],
    defaultRole: 'editor',
  }),
})

OAuth routes are added automatically at /auth/github + /auth/github/callback (or /auth/google + /auth/google/callback).


Protecting CMS routes

By default, all write operations (POST, PUT, DELETE, PATCH) require a valid session or API key. Read operations on published content can be configured to be public.

To require authentication for all reads:

withAuth({
  // ...
  requireAuthForReads: true,
})

To allow public reads of published content (default):

withAuth({
  // ...
  requireAuthForReads: false, // default
})

Using authentication in your frontend

Login form (drop-in UI)

import { LoginForm } from '@airdraft/react-ui'
import '@airdraft/react-ui/styles.css'

export default function LoginPage() {
  return <LoginForm apiUrl={process.env.NEXT_PUBLIC_CMS_API_URL!} />
}

Accept invite form

import { AcceptInviteForm } from '@airdraft/react-ui'

export default function AcceptInvitePage() {
  return <AcceptInviteForm apiUrl={process.env.NEXT_PUBLIC_CMS_API_URL!} />
}

useAuth hook

'use client'
import { useAuth } from '@airdraft/react'

export function UserMenu() {
  const { user, loading, logout } = useAuth()
  if (loading) return null
  if (!user) return <a href="/login">Sign in</a>
  return (
    <div>
      <span>{user.email}</span>
      <button onClick={logout}>Sign out</button>
    </div>
  )
}

The AirdraftProvider automatically refreshes the access token 60 seconds before it expires.

Resources

What's next