Authentication Setup
Configure Better Auth with passwordless sign-in, GitHub OAuth, and database options
Prerequisites:
Overview
UXLab uses Better Auth for authentication. All methods are passwordless:
| Method | Requirement | Best For |
|---|---|---|
| Magic Link | Resend API key | Passwordless email sign-in |
| Email OTP | Resend API key | 6-digit code via email |
| SMS OTP | Twilio account | Phone-based sign-in |
| GitHub OAuth | GitHub OAuth App | Developer-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.
Option 2: Turso (recommended for serverless)
Turso provides hosted libSQL databases with a generous free tier. Required for serverless platforms (Vercel, Netlify, Cloudflare) where the filesystem is ephemeral.
- Create a Turso account at turso.tech
- Create a database:
turso db create uxlab - Get the connection URL:
turso db show uxlab --url - Create an auth token:
turso db tokens create uxlab - 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 SQLite | Turso | Postgres/MySQL | |
|---|---|---|---|
| Setup | Zero config | Free account + 2 env vars | Hosted DB + code changes |
| Serverless | No (ephemeral FS) | Yes | Yes |
| Self-hosted | Yes | Yes | Yes |
| Cost | Free | Free tier (500 DBs, 9GB) | Varies |
| Code changes | None | None | server/auth.ts + server/utils/db.ts |
GitHub OAuth
- Go to GitHub Developer Settings
- Create a new OAuth App
- Set the Authorization callback URL to:
- Dev:
http://localhost:3000/api/auth/callback/github - Prod:
https://yourdomain.com/api/auth/callback/github
- Dev:
- Add to
.env:
NUXT_OAUTH_GITHUB_CLIENT_ID=Iv1.xxxxx
NUXT_OAUTH_GITHUB_CLIENT_SECRET=xxxxx
Magic Link / Email OTP
Both email-based methods require Resend for sending emails.
- Create account at resend.com
- Generate API key at resend.com/api-keys
- 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.
- Create account at twilio.com
- Get Account SID and Auth Token from the dashboard
- Purchase a phone number with SMS capability
- 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
adminon that person's next sign-in — including one whose account predates the entry. - An address removed from the list is demoted back to
vieweron 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 ownsadminand nothing else. The admin UI therefore does not offeradminat all — its role picker grantseditorandviewer, 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
adminis 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
403and the codeMEMBERSHIP_BY_INVITATION_ONLY. ADMIN_EMAILScounts as an invitation; it does not need repeating inMEMBER_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
403and the codeMEMBERSHIP_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 usersagainst them is the check. - The check lives in the
user.create.beforedatabase 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_VERIFIEDand told to use the emailed link instead. - The admin plugin's
POST /api/auth/admin/create-userroute is not a way in, even for a listed address: an admin-created row has proven nothing. It is refused with the codeMEMBERSHIP_ADMIN_CREATE_CLOSEDand a message that says to list the address inMEMBER_EMAILSinstead. 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.