20 KiB
Authentication Guidelines
This document covers backend authentication integration using better-auth, including session management, protected procedures, and OAuth configuration.
1. Overview
Current Project Auth Contract: Session Organization and Profile APIs
1. Scope / Trigger
- Trigger: app shell and account settings need authenticated current-tenant switching and current-account profile updates.
- This repository currently uses the custom
teatea_sessionHTTP-only cookie plus Drizzle tables (sessions,accounts,organizations,memberships,roles), not a better-auth runtime.
2. Signatures
GET /api/auth/session: returns current account, active organization, available organization options, membership, and permissions.POST /api/auth/organization: switchessessions.activeOrganizationIdfor the current session.PATCH /api/account/profile: updates the authenticated account'snameandavatarUrl.
3. Contracts
GET /api/auth/sessionresponse payload:account: PublicAccount | nullorganization: Organization | nullorganizations: AccountOrganizationOption[]membership: Membership | nullpermissions: Permission[]
POST /api/auth/organizationrequest payload:{ organizationId: string }.POST /api/auth/organizationsuccess response payload includes:organization: Organization | nullorganizations: AccountOrganizationOption[]permissions: Permission[]
PATCH /api/account/profilerequest payload:{ name: string; avatarUrl: string }.- All responses use
{ success, reason, ...payload }andCache-Control: no-store. Organization.slugis the canonical tenant segment for authenticated workspace URLs. Organization create/update and setup slug generation must reject or rewrite reserved workspace segments frommodules/shared/lib/workspace-routing.tsso/app/settingsand similar product routes cannot be interpreted as tenants.- Switching organizations should return the selected organization and available organization options so the client can redirect to the same workspace section under the new slug.
4. Validation & Error Matrix
- Missing/expired session ->
success: false,401,未登录或会话已过期. - Empty
organizationId->success: false,400,机构不能为空. - Organization not in authenticated account's available organization list ->
success: false,403,无权切换到该机构. - Empty profile
name->success: false,400,用户名称不能为空.
5. Good/Base/Bad Cases
- Good: platform account with organization-read permission can switch among active organizations.
- Base: organization user can switch only among active memberships.
- Bad: never trust a client-provided organization ID without comparing against server-computed organization options.
6. Tests Required
- Session API asserts
organizationsincludesisActive,slug, androleLabel. - Organization switch asserts session row changes and forbidden org IDs are rejected.
- Profile update asserts current account only is updated and audit log is recorded.
7. Wrong vs Correct
Wrong:
await database.update(sessions).set({ activeOrganizationId: body.organizationId });
Correct:
const target = context.organizations.find((organization) => organization.id === organizationId);
if (!target) {
throw new Error("无权切换到该机构");
}
await database.update(sessions).set({ activeOrganizationId: organizationId }).where(eq(sessions.id, context.session.id));
What is better-auth
better-auth is a modern authentication library for TypeScript applications that provides:
- Session-based authentication with secure cookie management
- Multiple authentication methods (email/password, OAuth, magic links, passkeys)
- Built-in support for organizations and multi-tenancy
- Database adapter integration (Drizzle ORM)
- Two-factor authentication (2FA)
- Admin functionality
Session-based Authentication
The authentication system uses secure, HTTP-only cookies to manage user sessions:
- Sessions are stored in the database and cached in Redis for performance
- Session tokens are automatically validated on each request
- Cookie names are prefixed with
__Secure-in production (HTTPS)
Supported Providers
| Provider | Type | Description |
|---|---|---|
| Email/Password | Credential | Traditional email and password authentication |
| OAuth | Social login with Google account | |
| GitHub | OAuth | Social login with GitHub account |
| Magic Link | Passwordless | Email-based one-time login links |
| Passkey | Passwordless | WebAuthn/FIDO2 biometric authentication |
2. Auth Configuration
Server-side Auth Setup
The auth configuration is defined in the auth package:
// packages/auth/auth.ts
import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { db } from "@your-app/database";
import {
admin,
magicLink,
organization,
passkey,
twoFactor,
username,
} from "better-auth/plugins";
export const auth = betterAuth({
baseURL: process.env.APP_URL,
appName: "Your App Name",
// Database adapter
database: drizzleAdapter(db, {
provider: "pg",
}),
// Session configuration
session: {
expiresIn: 60 * 60 * 24 * 7, // 7 days in seconds
freshAge: 0,
},
// Account linking for OAuth providers
account: {
accountLinking: {
enabled: true,
trustedProviders: ["google", "github"],
},
},
// Plugins
plugins: [
username(),
admin(),
passkey(),
magicLink({
sendMagicLink: async ({ email, url }, request) => {
// Send magic link email
await sendEmail({
to: email,
templateId: "magicLink",
context: { url },
});
},
}),
organization({
sendInvitationEmail: async ({ email, id, organization }, request) => {
// Send organization invitation email
},
}),
twoFactor(),
],
});
// Export session type
export type Session = typeof auth.$Infer.Session;
Database Adapter (Drizzle)
better-auth uses Drizzle ORM for database operations. The required tables are automatically created:
user- User accountssession- Authentication sessionsaccount- OAuth provider accounts (Google, GitHub, etc.)verification- Email verification tokens
Session Configuration
session: {
// Session lifetime (default: 7 days)
expiresIn: 60 * 60 * 24 * 7,
// Fresh session age for sensitive operations (0 = always require re-auth)
freshAge: 0,
}
3. Protected Procedures
Procedure Types
The API layer provides three procedure types with different authentication levels:
// packages/api/orpc/procedures.ts
import { ORPCError, os } from "@orpc/server";
// Public procedure - no authentication required
export const publicProcedure = os
.$context<{ headers: Headers }>()
.use(logIdMiddleware);
// Protected procedure - requires authenticated user
export const protectedProcedure = publicProcedure.use(
async ({ context, next }) => {
const { session } = await getSessionWithCache(context.headers);
if (!session) {
throw new ORPCError("UNAUTHORIZED");
}
return await next({
context: {
session: session.session,
user: session.user,
},
});
},
);
// Admin procedure - requires admin role
export const adminProcedure = protectedProcedure.use(
async ({ context, next }) => {
if (context.user.role !== "admin") {
throw new ORPCError("FORBIDDEN");
}
return await next();
},
);
Using Protected Procedures
Basic protected endpoint:
// procedures/get-profile.ts
import { protectedProcedure } from "../../../orpc/procedures";
export const getProfile = protectedProcedure
.route({
method: "GET",
path: "/users/profile",
tags: ["Users"],
summary: "Get current user profile",
})
.handler(async ({ context }) => {
// Access authenticated user from context
const { user, session } = context;
return {
success: true,
reason: "Profile retrieved",
user: {
id: user.id,
email: user.email,
name: user.name,
},
};
});
Admin-only endpoint:
// procedures/list-users.ts
import { adminProcedure } from "../../../orpc/procedures";
import { z } from "zod";
export const listUsers = adminProcedure
.route({
method: "GET",
path: "/admin/users",
tags: ["Administration"],
summary: "List all users",
})
.input(
z.object({
limit: z.number().min(1).max(100).default(10),
offset: z.number().min(0).default(0),
}),
)
.handler(async ({ input: { limit, offset } }) => {
const users = await getUsers({ limit, offset });
return { users };
});
Accessing User Session in Context
The protected procedure middleware injects session data into the context:
interface ProtectedContext {
session: {
id: string;
userId: string;
expiresAt: Date;
// ... other session fields
};
user: {
id: string;
email: string;
name: string;
role: "user" | "admin";
// ... other user fields
};
}
Accessing context in handlers:
.handler(async ({ context, input }) => {
const { user, session } = context;
// Use user.id for database queries
const userOrders = await getOrdersByUserId(user.id);
// Check user role
if (user.role === "admin") {
// Admin-specific logic
}
return { success: true, reason: "Success", orders: userOrders };
});
Role-based Access Control
Custom role middleware:
// Create a middleware for specific roles
const organizationAdminProcedure = protectedProcedure.use(
async ({ context, input, next }) => {
const { organizationId } = input as { organizationId: string };
const membership = await getOrganizationMembership(
organizationId,
context.user.id
);
if (!membership || membership.role !== "owner") {
throw new ORPCError("FORBIDDEN", {
message: "Organization admin access required",
});
}
return await next({
context: {
...context,
organization: membership.organization,
},
});
},
);
Verifying organization membership:
// lib/membership.ts
import { getOrganizationMembership } from "@your-app/database";
export async function verifyOrganizationMembership(
organizationId: string,
userId: string,
) {
const membership = await getOrganizationMembership(organizationId, userId);
if (!membership) {
return null;
}
return {
organization: membership.organization,
role: membership.role,
};
}
4. Session Management
Session Caching
Sessions are cached in Redis to reduce database load:
// lib/session-cache.ts
import { auth } from "@your-app/auth";
import { redis } from "./redis";
const SESSION_CACHE_PREFIX = "session";
const SESSION_TTL = 60 * 60 * 24 * 7; // 7 days
export async function getSessionWithCache(
headers: Headers,
): Promise<{ session: Session | null; fromCache: boolean }> {
const sessionToken = getSessionTokenFromHeaders(headers);
if (!sessionToken) {
const fresh = await fetchSession(headers);
return { session: fresh, fromCache: false };
}
// Try cache first
const cached = await redis.get(`${SESSION_CACHE_PREFIX}:${sessionToken}`);
if (cached) {
return { session: JSON.parse(cached), fromCache: true };
}
// Fetch from database
const fresh = await auth.api.getSession({ headers });
if (fresh) {
// Cache the session
await redis.set(
`${SESSION_CACHE_PREFIX}:${sessionToken}`,
JSON.stringify(fresh),
{ ex: SESSION_TTL }
);
}
return { session: fresh, fromCache: false };
}
Getting Session Token from Headers
export function getSessionTokenFromHeaders(headers: Headers): string | null {
// Check Authorization header first
const authHeader = headers.get("Authorization");
if (authHeader?.startsWith("Bearer ")) {
return authHeader.slice("Bearer ".length);
}
// Fall back to cookie
const cookieHeader = headers.get("cookie");
if (!cookieHeader) {
return null;
}
const cookies = parseCookie(cookieHeader);
const cookieName = process.env.NODE_ENV === "production"
? "__Secure-better-auth.session_token"
: "better-auth.session_token";
return cookies[cookieName] ?? null;
}
Session Invalidation
// Delete session cache on logout or session change
export async function deleteSessionCache(sessionToken: string): Promise<void> {
await redis.del(`${SESSION_CACHE_PREFIX}:${sessionToken}`);
}
5. OAuth Integration
Google OAuth Setup
Configuration:
// auth.ts
socialProviders: {
google: {
clientId: process.env.GOOGLE_CLIENT_ID as string,
clientSecret: process.env.GOOGLE_CLIENT_SECRET as string,
scope: [
"email",
"profile",
"openid",
// Add additional scopes as needed
// "https://www.googleapis.com/auth/calendar",
],
// Get refresh token for offline access
accessType: "offline",
prompt: "consent",
},
},
Environment variables:
# .env
GOOGLE_CLIENT_ID=your-google-client-id
GOOGLE_CLIENT_SECRET=your-google-client-secret
GitHub OAuth Setup
socialProviders: {
github: {
clientId: process.env.GITHUB_CLIENT_ID as string,
clientSecret: process.env.GITHUB_CLIENT_SECRET as string,
scope: ["user:email"],
},
},
Accessing OAuth Tokens
To access stored OAuth tokens for API calls:
import { db } from "@your-app/database";
import { account } from "@your-app/database/drizzle/schema";
import { eq, and } from "drizzle-orm";
export async function getOAuthToken(userId: string, provider: string) {
const accountRecord = await db.query.account.findFirst({
where: and(
eq(account.userId, userId),
eq(account.providerId, provider)
),
});
if (!accountRecord) {
return null;
}
return {
accessToken: accountRecord.accessToken,
refreshToken: accountRecord.refreshToken,
expiresAt: accountRecord.accessTokenExpiresAt,
};
}
Token Refresh
better-auth handles token refresh automatically. For manual refresh:
import { auth } from "@your-app/auth";
export async function refreshOAuthToken(userId: string, provider: string) {
// Use auth API to refresh token
const result = await auth.api.refreshAccessToken({
userId,
providerId: provider,
});
return result;
}
6. Error Handling
Standard Auth Errors
Use oRPC error codes for authentication failures:
import { ORPCError } from "@orpc/server";
// User not authenticated
throw new ORPCError("UNAUTHORIZED");
// User authenticated but lacks permission
throw new ORPCError("FORBIDDEN", {
message: "Admin access required",
});
// Session expired
throw new ORPCError("UNAUTHORIZED", {
message: "Session expired, please login again",
});
Error Response Pattern
// Consistent error response structure
export const authErrorSchema = z.object({
success: z.literal(false),
reason: z.string(),
code: z.enum(["UNAUTHORIZED", "FORBIDDEN", "SESSION_EXPIRED"]).optional(),
});
// In handler
if (!hasPermission) {
return {
success: false,
reason: "You do not have permission to perform this action",
code: "FORBIDDEN",
};
}
Handling Session Expiration
// Graceful session expiration handling
export async function handleSessionExpiration(sessionToken: string) {
// Clear cache
await deleteSessionCache(sessionToken);
// Log the event
logger.info("Session expired", { sessionToken: sessionToken.slice(0, 10) });
throw new ORPCError("UNAUTHORIZED", {
message: "Your session has expired. Please login again.",
});
}
7. Best Practices
Always Validate Session in Protected Routes
// GOOD - Use protectedProcedure for authenticated endpoints
export const updateProfile = protectedProcedure
.route({ method: "PATCH", path: "/users/profile" })
.handler(async ({ context }) => {
// context.user is guaranteed to exist
});
// BAD - Manual session check in public procedure
export const updateProfile = publicProcedure
.handler(async ({ context }) => {
const session = await getSession(context.headers);
if (!session) throw new ORPCError("UNAUTHORIZED");
// Error-prone and inconsistent
});
Use Middleware for Reusable Auth Checks
// Create reusable middleware for common patterns
const withOrganization = async ({ context, input, next }) => {
const { organizationId } = input;
const membership = await verifyOrganizationMembership(
organizationId,
context.user.id
);
if (!membership) {
throw new ORPCError("FORBIDDEN", {
message: "Not a member of this organization",
});
}
return next({
context: { ...context, organization: membership.organization },
});
};
// Use in procedures
export const getOrganizationData = protectedProcedure
.use(withOrganization)
.handler(async ({ context }) => {
// context.organization is now available
});
Proper Error Responses
// Always return meaningful error messages
.handler(async ({ context, input }) => {
try {
const result = await performAction(input);
return { success: true, reason: "Action completed", data: result };
} catch (error) {
if (error instanceof ORPCError) {
throw error; // Re-throw oRPC errors
}
logger.error("Action failed", { error, userId: context.user.id });
return {
success: false,
reason: "An unexpected error occurred",
};
}
});
Secure Session Token Handling
// Never log full session tokens
logger.info("Session validated", {
sessionToken: `${token.substring(0, 10)}...`,
userId: session.user.id,
});
// Clear sensitive data from responses
const sanitizedUser = {
id: user.id,
email: user.email,
name: user.name,
// Don't include: passwordHash, sessionTokens, etc.
};
Cache Invalidation on Auth Events
// In auth hooks
hooks: {
after: createAuthMiddleware(async (ctx) => {
if (ctx.path.startsWith("/sign-out")) {
const sessionToken = getSessionTokenFromHeaders(ctx.headers);
if (sessionToken) {
await deleteSessionCache(sessionToken);
}
}
}),
}
Client-side Auth Usage
For client-side authentication, use the auth client:
// packages/auth/client.ts
import { createAuthClient } from "better-auth/react";
import {
adminClient,
magicLinkClient,
organizationClient,
passkeyClient,
twoFactorClient,
} from "better-auth/client/plugins";
export const authClient = createAuthClient({
plugins: [
magicLinkClient(),
organizationClient(),
adminClient(),
passkeyClient(),
twoFactorClient(),
],
});
Usage in React components:
import { authClient } from "@your-app/auth/client";
// Sign in
await authClient.signIn.email({
email: "user@example.com",
password: "password",
});
// Sign out
await authClient.signOut();
// Get current session
const session = await authClient.getSession();
// Use hooks
const { data: session, isPending } = authClient.useSession();
Quick Reference
| Task | Solution |
|---|---|
| Require authentication | Use protectedProcedure |
| Require admin role | Use adminProcedure |
| Get current user | Access context.user in handler |
| Get session data | Access context.session in handler |
| Check organization membership | Use verifyOrganizationMembership helper |
| Throw auth error | throw new ORPCError("UNAUTHORIZED") |
| Throw permission error | throw new ORPCError("FORBIDDEN") |