This document provides comprehensive documentation for TuvixRSS's authentication and user management system, now powered by Better Auth.
- Overview
- Better Auth Integration
- Authentication System
- User Management
- API Endpoints
- Database Schema
- Security Features
- Configuration
- Code References
TuvixRSS uses Better Auth for authentication, a modern authentication library that provides:
- Session Management: HTTP-only cookies (more secure than JWT tokens)
- Password Security: Built-in password hashing (scrypt)
- Rate Limiting: Built-in rate limiting with configurable storage
- Email Verification: Optional email verification for new accounts
- Username Support: Username-based login via Username plugin
- Admin Management: Admin role and banning via Admin plugin
- Multi-platform Support: Works with both SQLite (Docker) and D1 (Cloudflare)
- Better Auth session management (HTTP-only cookies)
- Username and email/password authentication
- Role-based access control (RBAC)
- Rate limiting disabled (using custom Cloudflare Workers rate limit bindings)
- Comprehensive security audit logging
- Password reset with secure tokens
- Flexible plan system with custom user limits
- Account banning capability
Location: packages/api/src/auth/better-auth.ts
Better Auth is configured with:
- Drizzle Adapter: Works with both SQLite and D1
- Username Plugin: Enables username-based login
- Admin Plugin: Provides role management and banning
- Rate Limiting: Disabled (using custom Cloudflare Workers rate limit bindings)
- Email Verification: Controlled by
global_settings.requireEmailVerification
Better Auth automatically handles these endpoints at /api/auth/*:
POST /api/auth/sign-up/email- Register with emailPOST /api/auth/sign-in/username- Login with usernamePOST /api/auth/sign-in/email- Login with emailPOST /api/auth/sign-out- LogoutGET /api/auth/session- Get current sessionPOST /api/auth/change-password- Change passwordPOST /api/auth/request-password-reset- Request password resetPOST /api/auth/reset-password- Reset password with token
Location: packages/app/src/lib/auth-client.ts
import { authClient } from "@/lib/auth-client";
// Get session
const { data: session } = authClient.useSession();
// Login
await authClient.signIn.username({ username, password });
// Register
await authClient.signUp.email({ email, password, name: username });
// Logout
await authClient.signOut();Implementation: Better Auth handles sessions automatically
Better Auth uses HTTP-only cookies for session management, which is more secure than JWT tokens stored in localStorage:
- Security: Cookies are HTTP-only (not accessible via JavaScript)
- Automatic: Sessions are managed automatically
- Expiration: Configurable session expiration
- Cross-domain: Supports cross-subdomain cookies
Session Check: packages/api/src/trpc/context.ts:50
const session = await auth.api.getSession({ headers });
if (session?.user) {
user = {
userId: session.user.id as number,
username: session.user.username || session.user.name || "",
role: session.user.role || "user",
};
}Implementation: Better Auth handles password hashing internally
Better Auth uses scrypt for password hashing (OWASP-recommended when argon2id is not available).
Better Auth default validation:
- Minimum length: 8 characters
- Maximum length: 128 characters
Note: The frontend registration form uses Better Auth's default validation (3-30 chars for username, 8+ chars for password).
Implementation: Better Auth rate limiting is disabled
Better Auth's built-in rate limiting has been disabled in favor of our custom Cloudflare Workers rate limit bindings system. Authentication endpoints are protected by:
- Account lockout - After
maxLoginAttemptsfailed attempts (configurable in global settings) - Custom API rate limiting - Applied to all authenticated endpoints via tRPC middleware
- Security audit logging - All authentication attempts are logged
Configuration: packages/api/src/auth/better-auth.ts:144
rateLimit: {
enabled: false, // Disabled - using custom rate limiting system instead
},See docs/guides/features/rate-limiting.md for complete rate limiting documentation.
Custom API Rate Limiting: packages/api/src/services/rate-limiter.ts
Custom API rate limiting (for tRPC endpoints) is still handled separately and based on user plans.
Implementation: packages/api/src/auth/security.ts:1
All authentication events are logged to the security_audit_log table via Better Auth hooks.
Hooks: packages/api/src/auth/better-auth.ts:161
hooks: {
after: createAuthMiddleware(async (ctx) => {
if (ctx.path.startsWith("/sign-up")) {
// Log registration
}
if (ctx.path.startsWith("/sign-in")) {
// Log login
}
if (ctx.path.startsWith("/sign-out")) {
// Log logout
}
}),
}Event Types: packages/api/src/auth/security.ts:14
login_success/login_failedregisterlogoutpassword_changepassword_reset_request/password_reset_successaccount_locked/account_unlockedadmin_created/admin_first_user/promoted_to_admin
Schema: packages/api/src/db/schema.ts:29
TuvixRSS implements a 2-level role-based access control system:
- Default role for all registered users
- Can access personal resources only
- Subject to plan limits
- Cannot access admin endpoints
- Full system access
- Can manage all users and plans
- Can view system statistics
- Can configure global settings
- Can ban/unban users (via Better Auth Admin plugin)
- Can set custom limits for individual users
First User Admin: If ALLOW_FIRST_USER_ADMIN is enabled (default), the first registered user is automatically promoted to admin.
Role Check Middleware: packages/api/src/trpc/init.ts:155
if (ctx.user.role !== "admin") {
throw new TRPCError({ code: "FORBIDDEN", message: "Admin access required" });
}Endpoint: Better Auth POST /api/auth/sign-up/email or tRPC auth.register
-
Input Validation
- Username: 3-30 characters (Better Auth default)
- Email: Valid email format
- Password: Minimum 8 characters (Better Auth default)
-
Better Auth Processing
- Better Auth handles duplicate checking
- Password is hashed with scrypt
- User is created in Better Auth's
usertable
-
Role Assignment
- If first user +
ALLOW_FIRST_USER_ADMIN=true: admin role with admin plan - Otherwise: user role with default plan (typically "free")
- If first user +
-
Account Creation
- User record synced to
userstable (for compatibility) - Default user settings created
- Usage stats initialized
- Security event logged
- User record synced to
-
Session Creation
- Better Auth creates session automatically
- Session cookie is set (HTTP-only)
Registration Code: packages/api/src/routers/auth.ts:35
Schema: packages/api/src/db/schema.ts:459
Plans define the resource limits for users:
Plan Fields:
id: Plan identifier (e.g., "free", "pro", "enterprise")name: Display namemaxSources: Maximum RSS sourcesmaxPublicFeeds: Maximum public feedsmaxCategories: Maximum categories (null = unlimited)apiRateLimitPerMinute: API request rate limitpublicFeedRateLimitPerMinute: Public feed access rate limit (per minute)priceCents: Price in centsfeatures: JSON string of plan features
Default Plans:
- Defined in
packages/api/src/config/plans.ts - Seeded on database initialization
Schema: packages/api/src/db/schema.ts:483
Admins can override plan limits for individual users via the user_limits table.
Limit Resolution: packages/api/src/services/limits.ts
When checking user limits, the system:
- Checks for custom limits in
user_limitstable (for maxSources, maxPublicFeeds, maxCategories only) - Falls back to plan defaults from
planstable - Returns the resolved limits
Note: Rate limits (apiRateLimitPerMinute, publicFeedRateLimitPerMinute) cannot be customized per-user. They are enforced by plan-specific Cloudflare Workers bindings:
- Free plan:
FREE_API_RATE_LIMITbinding (60/min) - Pro plan:
PRO_API_RATE_LIMITbinding (180/min) - Enterprise/admin plan:
ENTERPRISE_API_RATE_LIMITbinding (600/min)
To change a user's rate limit, change their plan.
Implementation: packages/api/src/routers/admin.ts:333
Admins can ban/unban user accounts using Better Auth's Admin plugin:
Ban User:
await adminRouter.banUser({
userId: 123,
banned: true,
reason: "Terms of service violation",
});Effects of Banning:
- User cannot log in
- Existing sessions are invalidated
- All API requests return
403 Forbidden - Ban check occurs in auth middleware
Middleware Check: packages/api/src/trpc/init.ts:56
if (userRecord.banned) {
throw new TRPCError({
code: "FORBIDDEN",
message: "Account banned. Please contact support.",
});
}Unbanning:
- Set
banned: falsevia admin endpoint - User can immediately log in again
- All restrictions lifted
Better Auth handles these endpoints automatically at /api/auth/*:
POST /api/auth/sign-up/email
Body: { email, password, name: username }POST /api/auth/sign-in/username
Body: { username, password }POST /api/auth/sign-outGET /api/auth/sessionPOST /api/auth/change-password
Body: { currentPassword, newPassword }POST /api/auth/request-password-reset
Body: { email, redirectTo }POST /api/auth/reset-password
Body: { token, newPassword }Router: packages/api/src/routers/auth.ts
These endpoints are kept for backward compatibility and use Better Auth internally:
auth.register({
username: string,
email: string,
password: string,
});Uses Better Auth's signUpEmail internally.
auth.login({
username: string,
password: string,
});Uses Better Auth's signInUsername internally.
auth.me();Returns current authenticated user information from Better Auth session.
auth.changePassword({
currentPassword: string,
newPassword: string,
});Uses Better Auth's changePassword internally.
auth.requestPasswordReset({
email: string,
});Uses Better Auth's requestPasswordReset internally.
auth.resetPassword({
token: string,
newPassword: string,
});Uses Better Auth's resetPassword internally.
Router: packages/api/src/routers/admin.ts
List Users: packages/api/src/routers/admin.ts:74
admin.listUsers({
limit: number,
offset: number,
role?: "user" | "admin",
plan?: string,
banned?: boolean,
search?: string
})Ban User: packages/api/src/routers/admin.ts:333
admin.banUser({
userId: number,
banned: boolean,
reason?: string
})Better Auth requires these tables (added via migration):
Location: packages/api/src/db/schema.ts:25
Better Auth's user table:
id: Primary keyname: Display nameemail: Email address (unique)emailVerified: Email verification statususername: Username (unique, via Username plugin)role: User role (via Admin plugin)banned: Ban status (via Admin plugin)createdAt,updatedAt: Timestamps
Location: packages/api/src/db/schema.ts:48
Session management:
id: Primary keytoken: Session token (unique)userId: References user.idexpiresAt: Session expirationipAddress,userAgent: Client information
Location: packages/api/src/db/schema.ts:66
Account providers (email/password, social, etc.):
id: Primary keyuserId: References user.idproviderId: Provider type (e.g., "credential" for email/password)password: Password hash (for credential provider)
Location: packages/api/src/db/schema.ts:92
Email verification and password reset tokens:
id: Primary keyidentifier: Email addressvalue: Verification tokenexpiresAt: Token expiration
Location: packages/api/src/db/schema.ts:107
User table (synced with Better Auth's user table):
id: Primary keyusername: Unique usernameemail: Unique emailpassword: Password hash (Better Auth uses account table)role: User roleplan: Plan IDbanned: Ban statuscreatedAt,updatedAt: Timestamps
Note: The users table is synced with Better Auth's user table for compatibility with existing code.
TuvixRSS implements multiple layers of security:
- Input Validation (Zod schemas, Better Auth validation)
- Authentication (Better Auth sessions + scrypt password hashing)
- Rate Limiting (Custom API rate limiting via Cloudflare Workers bindings)
- Authorization (Role-based access control)
- Audit Logging (Comprehensive event tracking)
- All queries use Drizzle ORM with parameterized statements
- No raw SQL with user input
- HTML stripped from user inputs where applicable
- Output encoding in frontend
- CORS configured to specific origins only
- HTTP-only cookies (Better Auth)
- Better Auth uses constant-time comparison
- Password verification doesn't leak timing information
- Registration: Returns generic "username/email exists" error
- Password reset: Always returns success regardless of email validity
- Custom rate limiting via Cloudflare Workers rate limit bindings
- Configurable thresholds and durations
- Per-endpoint rate limits
Required:
BETTER_AUTH_SECRET: Secret key for Better Auth (32+ characters recommended)BETTER_AUTH_URL: Base URL for Better Auth (usesBASE_URLif not set)
Optional:
NODE_ENV: Environment ("development", "production")ALLOW_FIRST_USER_ADMIN: Enable first user admin promotion (default: "true")DATABASE_PATH: Path to SQLite databaseBASE_URL: Base URL for email links
Location: packages/api/src/auth/better-auth.ts
Key configuration options:
database: Drizzle adapter configurationsecret: Authentication secretbaseURL: Base URL for Better AuthbasePath: API path (/api/auth)emailAndPassword.enabled: Enable email/password authemailVerification.sendOnSignUp: Control email verificationrateLimit: Rate limiting configurationplugins: Username and Admin plugins
Email verification is controlled by global_settings.requireEmailVerification:
- If enabled: New accounts must verify email before accessing the app
- If disabled: Accounts are immediately active after registration
- Admin users bypass email verification requirement
Configuration: packages/api/src/auth/better-auth.ts:177
Verification Flow:
- User registers → Verification email sent automatically (if
requireEmailVerificationis enabled) - User clicks verification link → Email verified via Better Auth
/api/auth/verify-emailendpoint - User can check verification status via
auth.checkVerificationStatusendpoint - User can resend verification email via
auth.resendVerificationEmailendpoint (rate limited: 1 per 5 minutes)
Endpoints:
auth.checkVerificationStatus- Check if verification is required and current status (accessible to unverified users)auth.resendVerificationEmail- Resend verification email (accessible to unverified users, rate limited)
Route Protection:
- Unverified users are blocked from accessing protected endpoints (except verification endpoints)
- App route guard redirects unverified users to
/verify-emailpage - Middleware enforces verification check in
isAuthedmiddleware (admins bypass)
Email System Integration:
- Verification emails use dedicated
VerificationEmailtemplate - Email sending handled by
sendVerificationEmailfunction - See Email System Guide for complete email system documentation
| File | Description |
|---|---|
packages/api/src/auth/better-auth.ts |
Better Auth configuration |
packages/api/src/auth/security.ts |
Audit logging utilities |
packages/api/src/routers/auth.ts |
Authentication endpoints (tRPC) |
packages/api/src/routers/admin.ts |
Admin endpoints |
packages/api/src/trpc/init.ts |
tRPC middleware (auth, rate limit) |
packages/api/src/trpc/context.ts |
Better Auth session extraction |
packages/api/src/db/schema.ts |
Database schema definitions |
packages/app/src/lib/auth-client.ts |
Better Auth React client |
packages/app/src/lib/hooks/useAuth.ts |
React auth hooks |
Authentication Flow:
- Request arrives with session cookie
context.tsextracts Better Auth sessionisAuthedmiddleware checks authentication and ban statusisAdminmiddleware checks admin role (for admin endpoints)withRateLimitmiddleware checks API rate limits- Procedure handler executes
Reference: packages/api/src/trpc/init.ts
Check if user is authenticated:
export const myProcedure = protectedProcedure
.input(z.object({ ... }))
.mutation(async ({ ctx, input }) => {
// ctx.user is guaranteed to be defined
const userId = ctx.user.userId;
});Check if user is admin:
export const myAdminProcedure = adminProcedure
.input(z.object({ ... }))
.mutation(async ({ ctx, input }) => {
// ctx.user is guaranteed to be admin
});Frontend: Get current user:
import { useCurrentUser } from "@/lib/hooks/useAuth";
const { data: session } = useCurrentUser();
if (session?.user) {
// User is authenticated
}Last Updated: 2025-01-15