Data & Auth Integration Patterns¶
Architecture Overview¶
- Auth.js (NextAuth v5): Session-based authentication and tenant-aware identity, configured with the Credentials provider (additional OAuth providers may be added later).
- Kysely: Type-safe SQL query builder for all server-side data access. Targets PostgreSQL in production and SQLite locally — write portable SQL.
- Zod: Schema validation at every API and server-action boundary; same schemas drive OpenAPI generation.
- bcryptjs: Password hashing for the Credentials provider.
- Server-only: Auth.js, Kysely, and the database driver MUST stay on the server. Never import them from a
'use client'module.
Environment Variables¶
Server-Side Variables (Private)¶
Auth.js and database variables are server-only and MUST NOT use the NEXT_PUBLIC_ prefix:
# Auth.js core
AUTH_SECRET=replace-with-32-byte-random-string
AUTH_URL=https://example.com
AUTH_TRUST_HOST=true
# OAuth provider keys (only set the providers you have enabled)
AUTH_GOOGLE_ID=your_google_client_id
AUTH_GOOGLE_SECRET=your_google_client_secret
# Kysely / database
DATABASE_URL=postgres://user:password@host:5432/lms
For local development with SQLite, point DATABASE_URL at a file path (e.g. sqlite:./data/dev.sqlite).
Client-Side Variables (Public)¶
There are no client-visible auth or database variables. Anything sensitive must remain server-only.
Authentication Patterns¶
Auth.js Configuration¶
- Configuration lives in
src/auth.tsand exportsauth,signIn,signOut, andhandlers(the v5 idiom). src/app/api/auth/\[...nextauth\]/route.tsre-exportshandlers.GETandhandlers.POST.- Session strategy is JWT. Augment the session in the
jwt/sessioncallbacks to includeuserId,role, andtenantId.
Server-Side Session Access¶
- In server components, route handlers, and server actions, call
await auth()to retrieve the current session. - Treat
auth()as the single source of truth for "is this request authenticated?". Do not parse cookies manually. - Return
Response401 /redirect('/login')when no session is present on a protected surface.
Client-Side Session Access¶
- Use
useSession()(fromnext-auth/react) only when the UI needs reactive session state (e.g. avatar, sign-out button). - For initial render of protected pages, prefer server components using
auth()and pass the resulting session down.
Session Claims vs Database Records¶
Session Claims (JWT)¶
- Used for fast role checks at the edge of every request.
- Stored in the encrypted JWT cookie issued by Auth.js.
- Updated via the
jwtcallback. Keep the payload small.
Database Records (Kysely)¶
- Source of truth for user data and role assignments.
userstable holdsid,email,password_hash,role,tenant_id,created_at, etc.- Privileged actions (role promotions by
tenant_admin/super_admin, course-admin assignments, billing, deletions, impersonation) MUST re-read the database — never trust the JWT alone.
Consistency¶
- When a role changes, update the database row first, then refresh the session (Auth.js issues a new JWT on next sign-in or via the
update()helper). - Do not store ephemeral state in the JWT — it is only refreshed on session events.
Database Authorization¶
There is no declarative database rule engine. Authorization is enforced in application code:
// Example: protected route handler
import { auth } from "@/auth";
import { db } from "@/db";
import { z } from "zod";
const enrollSchema = z.object({
courseSlug: z.string().min(1),
});
export async function POST(request: Request) {
const session = await auth();
if (!session?.user?.id) {
return new Response("Unauthorized", { status: 401 });
}
const parsed = enrollSchema.safeParse(await request.json());
if (!parsed.success) {
return new Response("Bad Request", { status: 400 });
}
const enrollment = await db
.insertInto("enrollments")
.values({
user_id: session.user.id,
tenant_id: session.user.tenantId,
course_slug: parsed.data.courseSlug,
status: "enrolled",
})
.returningAll()
.executeTakeFirstOrThrow();
return Response.json(enrollment);
}
Every multi-tenant query MUST scope by tenant_id; every per-user query MUST scope by user_id.
Admin Routes¶
import { auth } from "@/auth";
export async function GET() {
const session = await auth();
const role = session?.user?.role;
if (role !== "tenant_admin" && role !== "super_admin") {
return new Response("Forbidden", { status: 403 });
}
// ...tenant_admin / super_admin logic. For per-course endpoints,
// also accept `course_admin` after verifying the course slug is in
// `session.user.assignedCourses`. See user-management.md.
}
Schema & Migrations¶
- The Kysely
Databasetype lives insrc/db/types.tsand is the only handwritten source of truth for table shapes. There is no codegen. - Migrations live in
src/db/migrations/and run viatsxscripts (e.g.npm run db:migrate). - Use Kysely's
MigratorAPI. Each migration must implement bothupanddown. - After changing a table, update
src/db/types.tsin the same commit.
Error Handling¶
Authentication Errors¶
- Auth.js redirects to the configured
pages.errorroute on failure. Surface a friendly message and a retry path; never leak provider error codes. - Treat expired sessions as anonymous — middleware (
proxy.ts) redirects to/login?callbackUrl=....
Database Errors¶
- Wrap Kysely calls with try/catch in route handlers and server actions; map known errors (unique constraint, foreign key) to user-friendly messages.
- Never surface raw SQL or stack traces to the client.
- Log the original error server-side with enough context (route, user id, tenant id) to debug.
User Management¶
Role Assignment¶
- New users default to the
studentrole on first sign-in (or on invitation acceptance, the role from theinvitationsrow). tenant_adminusers promote others within{ student, course_admin, tenant_admin }for their own tenant;super_admincan act across tenants. Promotion writes the new role via Kysely, bumpsusers.token_versionto invalidate outstanding JWTs, and triggers a session refresh on the affected user's next request.course_adminscope is assigned per-course viacourse_admin_assignments; managing assignments is atenant_admin/super_adminaction.
User Lifecycle¶
- On first sign-in, insert a row into
usersif one does not already exist. - On profile changes, update the
usersrow. The next session refresh propagates the change to the JWT. - On deletion, cascade through
enrollments,quiz_attempts, etc., or soft-delete by toggling adeleted_atcolumn — pick one strategy per table and document it insrc/db/types.ts.
Performance Optimization¶
Caching¶
- Server components cache naturally through Next.js fetch caching; for Kysely queries, use
unstable_cacheor React'scache()helper for stable inputs. - Index frequently filtered columns (
user_id,tenant_id,course_slug). - Avoid N+1 queries — prefer a single
joinover a loop of.executeTakeFirst()calls.
Query Optimization¶
- Use
select()to fetch only the columns you need. - Use
limit()+ cursor-based pagination for large lists. - Profile with
EXPLAINin PostgreSQL when a query is slow.
Security Best Practices¶
- Never expose
AUTH_SECRET, OAuth client secrets, orDATABASE_URLto the client bundle. - Always validate user permissions server-side, even after a session check.
- Hash credentials passwords with bcryptjs (cost factor ≥ 12) before insert.
- Rotate
AUTH_SECRETon a defined cadence; document the rotation process. - Sanitize all user inputs with Zod schemas before any Kysely insert/update.