Guides
Guides Authentication Setup

Authentication Setup

Configure Better Auth with passwordless sign-in, GitHub OAuth, and database options

AuthenticationBeginner

Prerequisites:

getting-started
AuthenticationSecurityOAUTHDatabase

Overview

UXLab uses Better Auth for authentication. All methods are passwordless:

MethodRequirementBest For
Magic LinkResend API keyPasswordless email sign-in
Email OTPResend API key6-digit code via email
SMS OTPTwilio accountPhone-based sign-in
GitHub OAuthGitHub OAuth AppDeveloper-friendly SSO

Required Environment Variables

Every deployment needs at minimum:

# Generate with: openssl rand -base64 32
BETTER_AUTH_SECRET=your-secret-minimum-32-chars

# Your app's base URL (used for OAuth callbacks)
BETTER_AUTH_URL=http://localhost:3000

Database Configuration

Better Auth stores users, sessions, and accounts in a SQLite-compatible database via Drizzle ORM and @libsql/client.

Option 1: Local SQLite (default, zero config)

When no database environment variables are set, UXLab uses a local SQLite file:

# No env vars needed — this is the default
# Data stored at .data/local.db (gitignored)

This is ideal for:

  • Local development
  • Self-hosted deployments (VPS, Docker, bare metal)
  • Trying out the project without any external accounts

The .data/ directory is gitignored. To share a pre-seeded database with collaborators, you could move it to a tracked location by setting TURSO_DATABASE_URL=file:data/local.db and committing the data/ directory.

Turso provides hosted libSQL databases with a generous free tier. Required for serverless platforms (Vercel, Netlify, Cloudflare) where the filesystem is ephemeral.

  1. Create a Turso account at turso.tech
  2. Create a database: turso db create uxlab
  3. Get the connection URL: turso db show uxlab --url
  4. Create an auth token: turso db tokens create uxlab
  5. Add to .env:
TURSO_DATABASE_URL=libsql://your-db-name-your-org.turso.io
TURSO_AUTH_TOKEN=your-token

Option 3: Other databases (requires code changes)

To use Postgres, MySQL, or another database, you would need to modify two files:

server/auth.ts — Change the Drizzle adapter:

// Current (SQLite/Turso via libsql)
import { createClient } from '@libsql/client'
import { drizzle } from 'drizzle-orm/libsql'
const client = createClient({ url: '...' })
const db = drizzle(client, { schema })

// Postgres example (would replace the above)
import postgres from 'postgres'
import { drizzle } from 'drizzle-orm/postgres-js'
const client = postgres(process.env.DATABASE_URL)
const db = drizzle(client, { schema })

Update the Better Auth adapter provider:

database: drizzleAdapter(db, {
  provider: 'pg', // was 'sqlite'
})

server/utils/db.ts — Same driver change for the legacy user operations.

Better Auth's schema is database-agnostic. The Drizzle schema in server/db/schema.ts may need dialect-specific adjustments (e.g., integer vs serial for auto-increment).

Comparison

Local SQLiteTursoPostgres/MySQL
SetupZero configFree account + 2 env varsHosted DB + code changes
ServerlessNo (ephemeral FS)YesYes
Self-hostedYesYesYes
CostFreeFree tier (500 DBs, 9GB)Varies
Code changesNoneNoneserver/auth.ts + server/utils/db.ts

GitHub OAuth

  1. Go to GitHub Developer Settings
  2. Create a new OAuth App
  3. Set the Authorization callback URL to:
    • Dev: http://localhost:3000/api/auth/callback/github
    • Prod: https://yourdomain.com/api/auth/callback/github
  4. Add to .env:
NUXT_OAUTH_GITHUB_CLIENT_ID=Iv1.xxxxx
NUXT_OAUTH_GITHUB_CLIENT_SECRET=xxxxx

Both email-based methods require Resend for sending emails.

  1. Create account at resend.com
  2. Generate API key at resend.com/api-keys
  3. Add to .env:
RESEND_API_KEY=re_xxxx
EMAIL_FROM_ADDRESS=UXLab <noreply@yourdomain.com>

For production, verify your sending domain in Resend.

SMS OTP

Requires a Twilio account for sending SMS messages.

  1. Create account at twilio.com
  2. Get Account SID and Auth Token from the dashboard
  3. Purchase a phone number with SMS capability
  4. Add to .env:
TWILIO_ACCOUNT_SID=ACxxxxx
TWILIO_AUTH_TOKEN=xxxxx
TWILIO_PHONE_NUMBER=+1234567890

Admin Role

ADMIN_EMAILS is the source of truth for the admin role. Users whose address appears in it are assigned admin on sign-up:

ADMIN_EMAILS=victor@example.com,admin@uxlab.io

The list is re-read on every login and the stored role synced to it in both directions:

  • An address added to the list is promoted to admin on that person's next sign-in — including one whose account predates the entry.
  • An address removed from the list is demoted back to viewer on theirs. Editing the variable is how Owner is granted, so it is how Owner is revoked.
  • Any other role (editor, say) is left alone: this list owns admin and nothing else. The admin UI therefore does not offer admin at all — its role picker grants editor and viewer, the roles the sync leaves alone, and an Owner's row shows a badge pointing at this variable instead (#223).
  • A deployment that lists no owner has none: every stored admin is demoted. Restoring the variable promotes them back on their next login.

Revocation lands on the next login, not immediately — an already-issued session keeps its role until then. To cut an account off right away, ban it (admin plugin) rather than only editing the list.

Membership Allowlist

Signing up is by invitation (ADR-0004). Only addresses listed in MEMBER_EMAILS or ADMIN_EMAILS can have an account created for them:

MEMBER_EMAILS=colleague@example.com,client@acme.com
  • Anyone else is refused with a 403 and the code MEMBERSHIP_BY_INVITATION_ONLY.
  • ADMIN_EMAILS counts as an invitation; it does not need repeating in MEMBER_EMAILS.
  • Both lists empty admits nobody new — an unconfigured deployment is closed, not open. The server warns on startup when that is the case, because nothing else about running it would tell you: it boots, it sends magic links, and it refuses only at the final step.
  • Separate addresses with a comma, and use no quotes. A semicolon, a space or a quote the shell or a dashboard field left behind makes the whole value one entry that matches no address, so a variable that looks configured admits nobody. The server warns on startup naming the variable and how many of its entries are inert — never their contents, which are still somebody's address. Entries are not repaired, only reported: guessing at the intended separator would mean admitting people by inference.
  • The lists are re-read on every sign-in, not only at sign-up. Taking an address off both lists ends that person's membership at their next sign-in: they are refused with a 403 and the code MEMBERSHIP_REVOKED, their row is kept, and re-listing the address is the whole undo. This is fail-closed, as the admin-role sync is: an address on neither list cannot sign in, including one admitted before the lists existed, and both lists empty means nobody signs in at all. Keep the variables accurate — select email from users against them is the check.
  • The check lives in the user.create.before database hook (server/utils/membership.ts), which magic link, email OTP, GitHub OAuth and phone all converge on.
  • Phone sign-in cannot create an account at all — it has no address to check — so a number can only be linked to an account that already exists.
  • An invited address must also be verified before it admits anyone. The email paths prove this by construction; GitHub OAuth does not, so a GitHub account carrying an invited address it never verified is refused with the code MEMBERSHIP_ADDRESS_NOT_VERIFIED and told to use the emailed link instead.
  • The admin plugin's POST /api/auth/admin/create-user route is not a way in, even for a listed address: an admin-created row has proven nothing. It is refused with the code MEMBERSHIP_ADMIN_CREATE_CLOSED and a message that says to list the address in MEMBER_EMAILS instead. Nothing in the app calls it.

A refused email-OTP attempt still receives its code: the OTP is sent before any user is created, and the refusal happens when the code is submitted.

Protecting Routes

Use the auth middleware to require login:

<script setup lang="ts">
definePageMeta({ middleware: ['auth'] })
</script>

The guest middleware redirects authenticated users away (used on the login page):

<script setup lang="ts">
definePageMeta({ middleware: ['guest'] })
</script>

Routes under /app/* are automatically protected by the global app-auth middleware.

Authorization

Content visibility is controlled via frontmatter:

---
visibility: public    # Anyone
visibility: internal  # Logged-in users
visibility: private   # Author or admin only
---

See shared/utils/abilities.ts for available permission checks.

  • a: Appearance
  • ?: Keyboard shortcuts