.agents/skills/better-auth-best-practices/SKILL.md
Best practices and patterns for using Better Auth authentication library. Use when implementing authentication features, configuring Better Auth initialization, managing users and organizations, setting up OAuth providers, handling sessions, or integrating Better Auth with TypeScript applications. ALWAYS use when writing code that imports from "better-auth" or "@op-plugin/authentication-better-auth", when designing authentication flows, configuring database schemas, setting up plugins, or handling session management. Also use for OAuth integration, email verification patterns, password reset flows, organization management, and role-based access.
npx skillsauth add em-jones/staccato-toolkit better-auth-best-practicesInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
Better Auth is a modern, type-safe authentication library for TypeScript applications. It provides built-in support for multiple authentication strategies, session management, user organization hierarchies, and extensible plugin systems.
Package: better-auth
Docs: https://better-auth.com/
Repo: https://github.com/better-auth/better-auth
License: MIT
Client Request
|
v
Better Auth Middleware
|
v
Authentication Strategy (email/password, OAuth, etc.)
|
v
Database Operations (User, Session, Account)
|
v
Session/Token Generation
|
v
Response to Client
Better Auth abstracts database operations and handles common authentication patterns, allowing you to focus on application logic rather than security concerns.
npm install better-auth
npm install drizzle-orm pg # for PostgreSQL
Or use the Open Portal integration:
npm install @op-plugin/authentication-better-auth @op-plugin/auth-core
Create .env.local:
# Database
AUTH_DATABASE_URL=postgresql://user:password@localhost:5432/auth_db
# Session secret (generate with: openssl rand -base64 32)
AUTH_SECRET=your-very-secret-key-here
# Application URLs
AUTH_BASE_URL=http://localhost:3000
AUTH_CALLBACK_URL=http://localhost:3000/api/auth/callback
# OAuth Providers (optional)
GOOGLE_CLIENT_ID=your-google-client-id
GOOGLE_CLIENT_SECRET=your-google-client-secret
GITHUB_CLIENT_ID=your-github-client-id
GITHUB_CLIENT_SECRET=your-github-client-secret
Better Auth automatically creates required tables. Ensure your database is:
-- PostgreSQL database created
CREATE DATABASE auth_db;
Better Auth will create these core tables on first initialization:
user - User accountssession - Active sessionsaccount - Connected OAuth/social accountsverification - Email verification tokenspassword_reset - Password reset tokensorganization - Organization records (with plugins)organization_member - Organization membershipimport { betterAuth } from "better-auth";
const auth = betterAuth({
database: {
type: "postgres",
url: process.env.AUTH_DATABASE_URL,
},
secret: process.env.AUTH_SECRET,
baseURL: process.env.AUTH_BASE_URL,
});
import { betterAuth } from "better-auth";
import { organization } from "better-auth/plugins";
const auth = betterAuth({
database: {
type: "postgres",
url: process.env.AUTH_DATABASE_URL,
},
secret: process.env.AUTH_SECRET,
baseURL: process.env.AUTH_BASE_URL,
plugins: [
organization({
// Optional: customize organization plugin behavior
sendInvitationEmail: true,
allowUserToCreateOrganization: true,
}),
],
});
const auth = betterAuth({
database: { type: "postgres", url: process.env.AUTH_DATABASE_URL },
secret: process.env.AUTH_SECRET,
socialProviders: {
google: {
clientId: process.env.GOOGLE_CLIENT_ID,
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
},
github: {
clientId: process.env.GITHUB_CLIENT_ID,
clientSecret: process.env.GITHUB_CLIENT_SECRET,
},
},
});
import { initializeBetterAuth } from "@op-plugin/authentication-better-auth";
const { auth } = initializeBetterAuth({
databaseUrl: process.env.AUTH_DATABASE_URL,
secret: process.env.AUTH_SECRET,
baseUrl: process.env.AUTH_BASE_URL,
});
// Via signup endpoint (typical flow)
const response = await auth.api.signUpEmail({
email: "[email protected]",
password: "secure-password",
name: "John Doe",
});
// Direct database access (admin)
const user = await auth.db.user.create({
email: "[email protected]",
name: "Admin User",
emailVerified: true, // Skip email verification for admins
});
// By user ID
const user = await auth.db.user.findUnique({
where: { id: "user-id" },
});
// By email
const user = await auth.db.user.findUnique({
where: { email: "[email protected]" },
});
// With related data
const user = await auth.db.user.findUnique({
where: { id: "user-id" },
include: {
sessions: true,
accounts: true, // OAuth connections
},
});
const updatedUser = await auth.db.user.update({
where: { id: "user-id" },
data: {
name: "New Name",
email: "[email protected]", // Triggers verification email
image: "https://example.com/avatar.jpg",
},
});
// Delete user and cascade delete sessions, accounts, etc.
await auth.db.user.delete({
where: { id: "user-id" },
});
Add custom fields to users:
const auth = betterAuth({
// ... other config
user: {
additionalFields: {
role: {
type: "string",
default: "user",
required: false,
},
department: {
type: "string",
required: false,
},
timezone: {
type: "string",
default: "UTC",
},
},
},
});
1. User authenticates (email/password or OAuth)
2. Better Auth creates session + token
3. Client stores token (cookie, localStorage, etc.)
4. Client includes token in subsequent requests
5. Server validates token and resolves user
6. Session expires or user logs out
7. Token is invalidated/deleted
// From Express request
const session = await auth.api.getSession({ request: req });
if (!session) {
return res.status(401).json({ error: "Not authenticated" });
}
console.log(session.user.id, session.user.email);
const auth = betterAuth({
// ... other config
session: {
cookiePrefix: "auth_", // Default cookie prefix
expiresIn: 7 * 24 * 60 * 60, // 7 days in seconds
updateAge: 24 * 60 * 60, // Refresh token every 24 hours
absoluteTimeout: 30 * 24 * 60 * 60, // Force re-login after 30 days
},
});
// On middleware/before handler
const session = await auth.api.getSession({ request: req });
if (!session) {
return res.status(401).json({ error: "Unauthorized" });
}
// Session is now available for use
const userId = session.user.id;
const userEmail = session.user.email;
// Logout user (delete all sessions)
await auth.api.signOut({ request: req });
// Or delete specific session
await auth.db.session.delete({
where: { id: "session-id" },
});
// Revoke all sessions for a user
await auth.db.session.deleteMany({
where: { userId: "user-id" },
});
const organization = await auth.db.organization.create({
data: {
name: "Acme Corp",
slug: "acme-corp", // URL-friendly identifier
},
});
// Add user to organization with role
await auth.db.organizationMember.create({
data: {
userId: "user-id",
organizationId: "org-id",
role: "member", // 'owner', 'admin', 'member'
},
});
// Create invitation (assumes invitation table exists)
await auth.db.invitation.create({
data: {
email: "[email protected]",
organizationId: "org-id",
role: "member",
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // 7 days
},
});
// Verify invitation and add user
const invitation = await auth.db.invitation.findUnique({
where: { token: "invitation-token" },
});
if (invitation && invitation.expiresAt > new Date()) {
// Create user or link existing user
await auth.db.organizationMember.create({
data: {
userId: "user-id",
organizationId: invitation.organizationId,
role: invitation.role,
},
});
}
// Get organization with members
const org = await auth.db.organization.findUnique({
where: { id: "org-id" },
include: {
members: {
include: { user: true },
},
},
});
// Get user's organizations
const userOrgs = await auth.db.organizationMember.findMany({
where: { userId: "user-id" },
include: { organization: true },
});
// Get organization members
const members = await auth.db.organizationMember.findMany({
where: { organizationId: "org-id" },
include: { user: true },
});
1. User signs up
2. Better Auth sends verification email
3. Email contains verification link with token
4. User clicks link
5. Token is validated and email marked as verified
6. User can now use full features
// Automatically sent on signup
const response = await auth.api.signUpEmail({
email: "[email protected]",
password: "password",
name: "User",
// Verification email automatically sent
});
// Manual verification email
await auth.sendVerificationEmail({
email: "[email protected]",
url: "http://localhost:3000/verify-email", // Callback URL
});
// Handle verification callback
const token = req.query.token as string;
const email = req.query.email as string;
const verified = await auth.verifyEmail({
email,
token,
});
if (!verified) {
return res.status(400).json({ error: "Invalid or expired token" });
}
return res.json({ success: true });
const auth = betterAuth({
// ... other config
emailVerification: {
sendVerificationEmail: true,
sendOnSignUp: true,
autoConfirmEmail: false, // Require manual confirmation
},
// Custom email template
async sendEmail(email, options) {
// Use your email service (SendGrid, AWS SES, Resend, etc.)
await sendEmail({
to: email,
subject: options.subject,
html: options.html,
});
},
});
1. User requests password reset
2. Better Auth generates reset token and sends email
3. User clicks email link with token
4. User submits new password
5. Token validated and password updated
6. User can login with new password
// Request password reset
const reset = await auth.api.forgetPassword({
email: "[email protected]",
redirectURL: "http://localhost:3000/reset-password",
});
// Email with reset link sent automatically
// User clicks reset link and arrives at /reset-password?token=...
const token = req.query.token as string;
const newPassword = req.body.password as string;
const resetResult = await auth.api.resetPassword({
token,
password: newPassword,
});
if (!resetResult) {
return res.status(400).json({ error: "Invalid or expired token" });
}
return res.json({ success: true, message: "Password reset successful" });
const auth = betterAuth({
database: { type: "postgres", url: process.env.AUTH_DATABASE_URL },
secret: process.env.AUTH_SECRET,
socialProviders: {
google: {
clientId: process.env.GOOGLE_CLIENT_ID,
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
},
github: {
clientId: process.env.GITHUB_CLIENT_ID,
clientSecret: process.env.GITHUB_CLIENT_SECRET,
// Optional: request additional scopes
scopes: ["user:email", "read:user"],
},
},
});
// Client-side: redirect to OAuth provider
window.location.href = "/api/auth/signin/google";
// Or use Better Auth client SDK
import { createAuthClient } from "better-auth/client";
const { signIn } = createAuthClient();
await signIn.social({
provider: "google",
callbackURL: "/dashboard",
});
Better Auth handles OAuth callbacks automatically:
GET /api/auth/callback/google?code=...&state=...
-> Better Auth validates code
-> Creates or updates user
-> Sets session
-> Redirects to callbackURL
Link multiple OAuth accounts to one user:
// User clicks "Connect Google"
await signIn.social({
provider: "google",
callbackURL: "/settings/connected-accounts",
linkAccount: true, // Link to existing account
});
import express from "express";
import { betterAuth } from "better-auth";
const auth = betterAuth({
/* ... */
});
const app = express();
// Mount auth endpoints
app.use("/api/auth/*", auth.handler);
// Session middleware
app.use(async (req, res, next) => {
req.session = await auth.api.getSession({ request: req });
next();
});
// Protected route
app.get("/api/user", (req, res) => {
if (!req.session) return res.status(401).json({ error: "Unauthorized" });
res.json({ user: req.session.user });
});
// app/api/auth/[...auth]/route.ts
import { betterAuth } from "better-auth";
import { toNextJsHandler } from "better-auth/next-js";
const auth = betterAuth({
/* ... */
});
export const { GET, POST } = toNextJsHandler(auth);
// app/api/user/route.ts
import { betterAuth } from "better-auth";
const auth = betterAuth({
/* ... */
});
export async function GET(request: Request) {
const session = await auth.api.getSession({ request });
if (!session) return Response.json({ error: "Unauthorized" }, { status: 401 });
return Response.json({ user: session.user });
}
import { Hono } from "hono";
import { betterAuth } from "better-auth";
const auth = betterAuth({
/* ... */
});
const app = new Hono();
// Mount auth endpoints
app.all("/api/auth/*", async (c) => {
return auth.handler(c.req.raw);
});
// Middleware for session
app.use(async (c, next) => {
const session = await auth.api.getSession({ request: c.req.raw });
c.set("session", session);
await next();
});
// session.ts
import { betterAuth } from "better-auth";
const auth = betterAuth({
/* ... */
});
// Infer session type
export type Session = typeof auth.$Inferred.Session;
export type User = typeof auth.$Inferred.User;
// Use in route handlers
import { Session, User } from "./session";
app.get("/profile", (req: Request & { session: Session }) => {
const user: User = req.session.user;
return res.json(user);
});
import { createAuthClient } from "better-auth/client";
const client = createAuthClient({
baseURL: "http://localhost:3000",
});
// Type-safe sign in
const { data: session, error } = await client.signIn.email(
{
email: "[email protected]",
password: "password",
},
{
onRequest: () => console.log("Signing in..."),
onSuccess: (context) => {
// context.data is typed as Session
},
onError: (context) => {
// context.error is typed
},
},
);
import { sendEmail } from "@resend/emails";
const auth = betterAuth({
// ... other config
async sendEmail(email, options) {
await sendEmail({
from: "[email protected]",
to: email,
subject: options.subject,
html: options.html,
text: options.text,
});
},
});
const auth = betterAuth({
// ... other config
hooks: {
user: {
created: async (user) => {
// Send welcome email, create user profile, etc.
console.log("New user created:", user.id);
await sendWelcomeEmail(user.email);
},
},
},
});
import { rateLimit } from "./middleware";
app.post("/api/auth/signin/email", rateLimit(5, "15m"), async (req, res) => {
// Limit to 5 sign-in attempts per 15 minutes
return auth.handler(req, res);
});
const auth = betterAuth({
// ... other config
hooks: {
user: {
created: async (user) => {
await audit.log("USER_CREATED", { userId: user.id, email: user.email });
},
},
account: {
linked: async (account) => {
await audit.log("ACCOUNT_LINKED", { userId: account.userId, provider: account.provider });
},
},
},
});
import { twoFactor } from "better-auth/plugins";
const auth = betterAuth({
database: { type: "postgres", url: process.env.AUTH_DATABASE_URL },
secret: process.env.AUTH_SECRET,
plugins: [
twoFactor({
issuer: "My App",
}),
],
});
// Client-side
const { signUp } = useAuth();
await signUp.email(
{
email: "[email protected]",
password: "secure-password",
name: "John Doe",
},
{
onSuccess: () => {
// Check email for verification link
navigate("/check-email");
},
},
);
const { signIn } = useAuth();
await signIn.email(
{
email: "[email protected]",
password: "password",
},
{
onSuccess: () => {
navigate("/dashboard");
},
},
);
async function protectedRoute(req: Request) {
const session = await auth.api.getSession({ request: req });
if (!session) {
return Response.json({ error: "Unauthorized" }, { status: 401 });
}
// Use session.user for authorization
return Response.json({ data: `User: ${session.user.id}` });
}
// Assume you store role in user custom field
async function adminRoute(req: Request) {
const session = await auth.api.getSession({ request: req });
if (!session?.user?.role || session.user.role !== "admin") {
return Response.json({ error: "Forbidden" }, { status: 403 });
}
return Response.json({ data: "Admin data" });
}
async function getUserOrganizations(userId: string) {
const memberships = await auth.db.organizationMember.findMany({
where: { userId },
include: { organization: true },
});
return memberships.map((m) => m.organization);
}
async function getOrganizationUsers(orgId: string) {
const members = await auth.db.organizationMember.findMany({
where: { organizationId: orgId },
include: { user: true },
});
return members.map((m) => m.user);
}
import {
initializeBetterAuth,
createBetterAuthAdapter,
} from "@op-plugin/authentication-better-auth";
import { createRoleManagement } from "@op-plugin/auth-core";
// Initialize Better Auth
const { auth } = initializeBetterAuth({
databaseUrl: process.env.AUTH_DATABASE_URL,
secret: process.env.AUTH_SECRET,
});
// Create Auth API adapter
const authAPI = createBetterAuthAdapter({ auth });
// Create role management
const roleAPI = createRoleManagement(authAPI);
// Use in your application
const user = await authAPI.getUser("user-id");
const roles = await roleAPI.getRolesForUser("user-id");
import { createOrganizationManagement } from "@op-plugin/authentication-better-auth";
const orgMgmt = createOrganizationManagement({ auth });
// Add member
await orgMgmt.addMemberToOrganization("user-id", "org-id", "member");
// Get members
const members = await orgMgmt.getOrganizationMembers("org-id");
// Invite user
await orgMgmt.inviteUserToOrganization("org-id", "[email protected]", "member");
CREATE INDEX idx_user_email ON user(email))CREATE INDEX idx_session_user_id ON session(userId))| Issue | Solution |
| ----------------------------- | --------------------------------------------------------------------------- |
| AUTH_DATABASE_URL not found | Check .env.local file exists and contains AUTH_DATABASE_URL |
| Invalid AUTH_SECRET | Ensure AUTH_SECRET is set; generate one with openssl rand -base64 32 |
| Database connection failed | Verify PostgreSQL is running and AUTH_DATABASE_URL is correct |
| Email not sending | Check email provider configuration; review Better Auth email hooks |
| OAuth callback error | Verify OAuth credentials and redirect URLs match provider configuration |
| Session not persisting | Check browser cookie settings; ensure httpOnly and Secure flags correct |
| User not found after signup | Verify database tables created; check email verification requirements |
// Enable logging
const auth = betterAuth({
// ... other config
logger: {
debug: (msg) => console.debug("[Better Auth]", msg),
error: (msg, err) => console.error("[Better Auth]", msg, err),
},
});
// Inspect database state
const users = await auth.db.user.findMany();
const sessions = await auth.db.session.findMany();
console.log("Users:", users);
console.log("Sessions:", sessions);
| Import | Purpose |
| --------------------------------------- | ---------------------------------------------- |
| better-auth | Core library: betterAuth() |
| better-auth/client | Client SDK: createAuthClient() |
| better-auth/plugins | Plugins: organization(), twoFactor(), etc. |
| better-auth/next-js | Next.js handler: toNextJsHandler() |
| better-auth/hono | Hono handler: toHonoHandler() |
| better-auth/express | Express handler: toExpressHandler() |
| @op-plugin/authentication-better-auth | Open Portal integration |
| @op-plugin/auth-core | Core authentication interfaces |
tools
<!--VITE PLUS START--> # Using Vite+, the Unified Toolchain for the Web This project is using Vite+, a unified toolchain built on top of Vite, Rolldown, Vitest, tsdown, Oxlint, Oxfmt, and Vite Task. Vite+ wraps runtime management, package management, and frontend tooling in a single global CLI called `vp`. Vite+ is distinct from Vite, but it invokes Vite through `vp dev` and `vp build`. ## Vite+ Workflow `vp` is a global binary that handles the full development lifecycle. Run `vp help` to pr
development
Guide for building performant data tables. Uses tanstack-table for table logic (sorting, filtering, pagination) and tanstack-virtual for rendering large datasets efficiently.
development
Expert guidance for building observable, expressive, and fault-tolerant TypeScript applications using the effect-ts/effect ecosystem. Covers Effect<A, E, R> type, error management, dependency injection via Layers, observability (logging, metrics, tracing), concurrency with Fibers, retry/scheduling, Schema validation, Streams, and Sinks.
tools
Complete E2E (end-to-end) and integration testing skill for TypeScript/NestJS projects using Jest, real infrastructure via Docker, and GWT pattern. ALWAYS use this skill when user needs to: **SETUP** - Initialize or configure E2E testing infrastructure: - Set up E2E testing for a new project - Configure docker-compose for testing (Kafka, PostgreSQL, MongoDB, Redis) - Create jest-e2e.config.ts or E2E Jest configuration - Set up test helpers for database, Kafka, or Redis - Configure .env.e2e environment variables - Create test/e2e directory structure **WRITE** - Create or add E2E/integration tests: - Write, create, add, or generate e2e tests or integration tests - Test API endpoints, workflows, or complete features end-to-end - Test with real databases, message brokers, or external services - Test Kafka consumers/producers, event-driven workflows - Working on any file ending in .e2e-spec.ts or in test/e2e/ directory - Use GWT (Given-When-Then) pattern for tests **REVIEW** - Audit or evaluate E2E tests: - Review existing E2E tests for quality - Check test isolation and cleanup patterns - Audit GWT pattern compliance - Evaluate assertion quality and specificity - Check for anti-patterns (multiple WHEN actions, conditional assertions) **RUN** - Execute or analyze E2E test results: - Run E2E tests - Start/stop Docker infrastructure for testing - Analyze E2E test results - Verify Docker services are healthy - Interpret test output and failures **DEBUG** - Fix failing or flaky E2E tests: - Fix failing E2E tests - Debug flaky tests or test isolation issues - Troubleshoot connection errors (database, Kafka, Redis) - Fix timeout issues or async operation failures - Diagnose race conditions or state leakage - Debug Kafka message consumption issues **OPTIMIZE** - Improve E2E test performance: - Speed up slow E2E tests - Optimize Docker infrastructure startup - Replace fixed waits with smart polling - Reduce beforeEach cleanup time - Improve test parallelization where safe Keywords: e2e, end-to-end, integration test, e2e-spec.ts, test/e2e, Jest, supertest, NestJS, Kafka, Redpanda, PostgreSQL, MongoDB, Redis, docker-compose, GWT pattern, Given-When-Then, real infrastructure, test isolation, flaky test, MSW, nock, waitForMessages, fix e2e, debug e2e, run e2e, review e2e, optimize e2e, setup e2e