Authentication
Eventuall uses NextAuth (Auth.js v5 beta) for authentication with a custom D1/SQLite adapter. Users can sign in with Google OAuth or via OTP (one-time password) sent through Twilio.
How Authentication Works
When a user visits a protected page, the NextAuth middleware checks for a valid session cookie. If no session exists, the user is redirected to /login. After successful authentication, a session is created in the D1 database and a cookie (eventuall_session) is set in the browser.
sequenceDiagram
participant Browser
participant Middleware
participant NextAuth
participant D1 as D1 Database
participant Provider as Auth Provider
Browser->>Middleware: Request /event/123
Middleware->>Middleware: Check session cookie
alt No session
Middleware-->>Browser: Redirect to /login
Browser->>NextAuth: Sign in with Google/OTP
NextAuth->>Provider: Validate credentials
Provider-->>NextAuth: User identity
NextAuth->>D1: Create/update user + session
D1-->>NextAuth: Session token
NextAuth-->>Browser: Set cookie + redirect
else Valid session
Middleware-->>Browser: Allow request
end
Session Strategy
Sessions use the "database" strategy, not JWT. This means the session token in the cookie is an opaque reference — the actual session data lives in the D1 database. Sessions expire after 180 days.
Architect's Note: Database sessions allow server-side revocation (you can invalidate a session by deleting it from the database), but they add a database query on every authenticated request. JWT sessions would be faster but can't be revoked server-side. For a platform handling live events where you might need to immediately revoke a compromised session, database sessions are the safer choice.
Authentication Providers
Google OAuth
Google OAuth is the primary authentication method. The flow:
- User clicks "Sign in with Google"
- NextAuth redirects to Google's consent screen
- Google redirects back with an authorization code
- NextAuth exchanges the code for user information
- The system creates or links the user account
Configuration is in apps/webapp/src/server/auth/config.ts. Google OAuth requires AUTH_GOOGLE_ID and AUTH_GOOGLE_SECRET environment variables.
OTP (One-Time Password)
For users without Google accounts, Eventuall supports OTP authentication via Twilio Verify:
- User enters their phone number or email
- The system sends a verification code via Twilio
- User enters the code
- The system validates it against Twilio's Verify API
The OTP provider is a custom NextAuth Credentials provider defined in apps/webapp/src/server/auth/providers/OTP.ts. It handles three error states:
OTPTokenExpired— the code has timed outOTPTokenInvalid— wrong codeOTPMaxAttemptsReached— too many failed attempts
Pitfall: The Credentials provider in NextAuth doesn't support the standard session creation flow. Eventuall has a custom
createCredentialSession()function that manually creates a session record in D1 and sets the cookie. This is a workaround for a NextAuth limitation with database session strategy + credentials providers.
User Creation Flow
When a user signs in for the first time, the signIn event callback handles account creation:
- Look up the user by email in the database
- If the user doesn't exist, create them with the provider's profile data
- Create a "personal account" for the user (every user gets one)
- Link the OAuth provider to the user record in
userProviders
If the user already exists but signed in with a different provider (e.g., they first used Google, now using OTP), the system links the new provider to the existing account.
Custom D1 Adapter
NextAuth's built-in Drizzle adapter doesn't work with Cloudflare D1 because D1 requires a fresh database connection per request. The custom adapter in apps/webapp/src/server/auth/adapter/ wraps the standard Drizzle adapter to accept a function-based database client instead of a static instance:
// The adapter gets the DB connection lazily, per-request
export function DrizzleD1Adapter(clientFn: () => D1Database) {
// Each adapter method calls clientFn() to get a fresh D1 connection
}
This prevents the stale connection problem that would occur with a cached database client on Cloudflare.
Middleware
Route protection is handled in apps/webapp/src/middleware.ts. The middleware matches specific route patterns and checks authentication:
Protected routes: /event/* and /developer/* — redirects unauthenticated users to /login
Login redirect: Authenticated users visiting /login are redirected to / (prevents the login page from showing when already signed in)
Composite route exception: Routes with a special composite header bypass authentication. This allows LiveKit's egress service (which captures video) to access event pages without a user session.
Pitfall: The composite route exception is security-sensitive. It's gated on a specific header value, not IP whitelisting. If someone discovers the header value, they could access any event page without authentication. Consider adding IP-based restrictions for the egress service in production.
How tRPC Uses Authentication
The tRPC context creation (apps/webapp/src/server/api/trpc.ts) calls auth() to get the current session. This session is available to all tRPC procedures.
Public procedures (publicProcedure) don't require authentication — ctx.session may be null.
Protected procedures (protectedProcedure) add middleware that throws UNAUTHORIZED if no session exists. After this middleware, TypeScript narrows ctx.session to be non-null, so you get compile-time guarantees:
// In a protected procedure, ctx.session.user is guaranteed to exist
export const protectedProcedure = t.procedure.use(({ ctx, next }) => {
if (!ctx.session?.user) {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
return next({
ctx: { session: { ...ctx.session, user: ctx.session.user } },
});
});
Server Actions and Authentication
Server Actions use a separate authentication chain through next-safe-action. The authenticatedActionClient middleware validates the session and provides the user context:
export const authenticatedActionClient = publicActionClient
.use(async ({ next }) => {
const session = await auth();
if (!session?.user) {
throw new ActionError("You must be logged in", "UNAUTHORIZED");
}
return next({ ctx: { user: session.user, db: getDB() } });
});
This means both tRPC procedures and Server Actions have authenticated access to the database and user context, but through different code paths.
Role-Based Access Control (RBAC)
Beyond authentication, Eventuall has a permission system built on roles and access grants:
- Permissions are granular capabilities (e.g.,
view:stream,join:stage,manage:event) - Roles bundle permissions together (e.g., "moderator" has
manage:event+join:stage) - Roles can inherit from other roles through the
rolesInheritancestable - Access grants assign a user a role for a specific event + room combination
To check if a user can perform an action, the system looks up their access grants for the event/room, resolves the role (including inherited roles), and checks if the required permission exists.
Architect's Note: The RBAC system uses a SQL view (
rolePermissionsView) that pre-computes role-to-permission mappings including inheritance. This avoids recursive queries at runtime but means the view must be refreshed when role hierarchies change. Currently, role hierarchies are static (defined at seed time), but if you make them dynamic, consider the query cost implications.