Implement secure user authentication with role-based registration flows and JWT token management.
The Starter Kit provides a complete authentication system that delegates to the Cloud API (https://api.devkit4ai.com), supporting end-user registration and universal login with JWT-based session management.
Developer registration is handled through the Cloud Admin console at devkit4ai.com or vibecoding.ad. The Starter Kit includes developer registration support for compatibility but redirects project mode users to Cloud Admin. In project mode, the developer registration page redirects to /login.
The login page handles all user types with role-based redirects after authentication:Frontend Flow:
backendLoginAction(formData: FormData) -> Validates email and password presence -> Sanitizes returnUrl via sanitizeReturnUrl() -> POST /api/v1/auth/login -> Headers: X-Project-ID (project mode only) -> Body: { email, password } -> Response: { access_token, refresh_token } -> Store JWT tokens in httpOnly cookies -> GET /api/v1/auth/me to fetch user data -> Redirect based on role or returnUrl
Backend Implementation (10-step flow):
Endpoint Receives Request: POST /api/v1/auth/login with optional X-Project-ID header
Command Creation: LoginUserCommand with email, password, project_id (if provided)
User Lookup: Queries User table by email and project_id (for END_USER) or email only (for OPERATOR/DEVELOPER)
Password Verification: Uses bcrypt via pwd_context.verify(password, user.hashed_password)
Active Status Check: Validates user.is_active is True (email verified)
Aggregate Loading: Reconstructs UserActions from event stream via from_events()
// After successful login, user redirected based on role:platform_operator -> /portaldeveloper -> /consoleend_user -> /dashboard// Or to sanitized returnUrl if provided
(((REPLACE_THIS_WITH_IMAGE: login-form-interface.png: Screenshot of login form with email and password fields)))
# For END_USER with X-Project-IDuser = db.query(User).filter( User.email == email, User.project_id == project_id, User.role == UserRole.END_USER).first()# For OPERATOR/DEVELOPER without X-Project-IDuser = db.query(User).filter( User.email == email, User.project_id.is_(None), User.role.in_([UserRole.PLATFORM_OPERATOR, UserRole.DEVELOPER])).first()
Why Project Scoping?
Enables same email to exist as END_USER in multiple projects
Isolates user namespaces per project
Developer A’s end users cannot access Developer B’s project
JWT access tokens for END_USER include project_id claim
The X-Project-ID header is crucial for end user authentication. It ensures all requests are scoped to the correct project context. Without it, end user login will fail.
// app/dashboard/page.tsximport { getCurrentUser, requireAuth } from "@/lib/auth-server";// Option 1: Get user or nullexport default async function DashboardPage() { const user = await getCurrentUser(); if (!user) { return <div>Not authenticated</div>; } return <div>Welcome {user.full_name || user.email}!</div>;}// Option 2: Require authentication (redirects if not authenticated)export default async function ProtectedPage() { const user = await requireAuth(); // Page only renders if authenticated return <div>Hello {user.full_name}!</div>;}
getCurrentUser() Implementation:
// lib/auth-server.tsimport { cache } from "react";export const getCurrentUser = cache(async (): Promise<UserWithRole | null> => { const token = await getAccessToken(); if (!token) return null; const backendApiUrl = process.env.NEXT_PUBLIC_API_URL; if (!backendApiUrl) return null; // 10 second timeout protection const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), 10000); try { const response = await fetch(`${backendApiUrl}/api/v1/auth/me`, { headers: { "Authorization": `Bearer ${token}`, "Cache-Control": "no-cache", }, cache: "no-store", signal: controller.signal, }); clearTimeout(timeoutId); if (!response.ok) return null; const data = await response.json(); // Validate response structure if (!data?.id || !data?.email || !data?.role) return null; // Validate role value const validRoles = ["platform_operator", "developer", "end_user"]; if (!validRoles.includes(data.role)) return null; return data as UserWithRole; } catch { clearTimeout(timeoutId); return null; }});
React Cache Benefits:
Caches result per-request to avoid redundant API calls
Multiple calls to getCurrentUser() in same request cycle return same data
Cache automatically invalidated between requests
Fresh user data fetched on each new page load
Client Components:
// components/user-menu.tsx"use client";import { useCurrentUser, useIsAuthenticated } from "@/lib/auth-context";export function UserMenu() { const user = useCurrentUser(); const isAuthenticated = useIsAuthenticated(); if (!isAuthenticated) { return <LoginButton />; } return ( <div> <Avatar>{user?.email[0]}</Avatar> <span>{user?.full_name || user?.email}</span> {user?.project_id && <Badge>Project User</Badge>} </div> );}
User Data Structure:
interface UserWithRole { id: string; // User UUID email: string; // User email address full_name?: string | null; // Optional display name role: "platform_operator" | "developer" | "end_user"; is_active: boolean; // Email verification status created_at: string; // ISO 8601 timestamp project_id?: string | null; // UUID (only for end_user role)}
The project_id field is only present for end users and identifies which project they belong to. Developers and operators do not have a project_id.
Use requireRole() to enforce role-based access control:
// app/console/page.tsximport { requireRole } from "@/lib/auth-server";export default async function ConsolePanel() { const user = await requireRole(["platform_operator", "developer"]); // Only operators and developers can access // Other roles redirected to their default dashboard return <div>Console Dashboard</div>;}
requireRole() Implementation:
// lib/auth-server.tsexport async function requireRole( allowedRoles: Array<"platform_operator" | "developer" | "end_user">): Promise<UserWithRole> { const user = await requireAuth(); // First ensure authenticated if (!allowedRoles.includes(user.role)) { // Redirect to role-based dashboard if unauthorized const dashboardPath = getRoleBasedRedirect(user.role); redirect(dashboardPath); } return user;}function getRoleBasedRedirect(role: string): string { switch (role) { case "platform_operator": return "/portal"; case "developer": return "/console"; case "end_user": return "/dashboard"; default: return "/dashboard"; }}
Use hooks for conditional rendering without redirects:
// components/settings-button.tsx"use client";import { useRequireRole, useHasRole } from "@/lib/auth-context";// Option 1: useRequireRole (returns user or null)function SettingsButton() { const user = useRequireRole(["developer"]); if (!user) return null; // Hide for non-developers return <button>Settings</button>;}// Option 2: useHasRole (returns boolean)function AdminPanel() { const hasAccess = useHasRole(["platform_operator"]); if (!hasAccess) { return <div>Access Denied</div>; } return <div>Admin Panel Content</div>;}
Auth Context Hooks:
// lib/auth-context.tsximport { useContext } from "react";export function useAuth(): AuthContextValue { return useContext(AuthContext);}export function useCurrentUser(): UserWithRole | null { const { user } = useAuth(); return user;}export function useIsAuthenticated(): boolean { const { user } = useAuth(); return user !== null;}export function useRequireRole( allowedRoles: Array<"platform_operator" | "developer" | "end_user">): UserWithRole | null { const user = useCurrentUser(); if (!user) return null; return allowedRoles.includes(user.role) ? user : null;}export function useHasRole( allowedRoles: Array<"platform_operator" | "developer" | "end_user">): boolean { const user = useCurrentUser(); if (!user) return false; return allowedRoles.includes(user.role);}
Client-side hooks DO NOT redirect users. They only return null or false for unauthorized access. Use server-side requireAuth() or requireRole() for page-level protection with automatic redirects.
## Password RequirementsAll passwords must meet these criteria enforced server-side by the UserActions aggregate:- Minimum 8 characters- At least one uppercase letter (A-Z)- At least one lowercase letter (a-z)- At least one digit (0-9)**Backend Validation:**```python# app/features/auth/actions.py - UserActions.register()def register(email, password, user_id, role, project_id=None, full_name=None): # Validate password length if len(password) < 8: raise ValueError("Password must be at least 8 characters long") # Check for uppercase letter if not any(c.isupper() for c in password): raise ValueError( "Password must contain at least one uppercase letter" ) # Check for lowercase letter if not any(c.islower() for c in password): raise ValueError( "Password must contain at least one lowercase letter" ) # Check for digit if not any(c.isdigit() for c in password): raise ValueError("Password must contain at least one digit") # Hash password with bcrypt hashed_password = pwd_context.hash(password)
Frontend Validation:
// app/actions.ts - backendRegisterAction()const passwordRegex = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).{8,}$/;if (!passwordRegex.test(password)) { return { success: false, error: "Password must be at least 8 characters with uppercase, lowercase, and digit" };}
Password Hashing:
Algorithm: bcrypt via passlib.context.CryptContext
Work factor: Default bcrypt rounds (2^12 iterations)
Stored in users.hashed_password column (VARCHAR 255)
POST /api/v1/projectsHeaders: Content-Type: application/json Authorization: Bearer <jwt_access_token> X-Developer-Key: ak_abc123XYZ-_789def456ghi012jkl345
Platform Operator Requests:
POST /api/v1/admin/usersHeaders: Content-Type: application/json Authorization: Bearer <jwt_access_token> X-Operator-Key: <operator_key_from_settings>
Header Resolution:
// lib/deployment-mode.ts - hydrateDeploymentMode()function resolveHeaders(mode, secrets) { const headers: Record<string, string> = {}; if (mode === "operator") { if (secrets.operatorKey) { headers["X-Operator-Key"] = secrets.operatorKey; } } else if (mode === "console") { if (secrets.developerKey) { headers["X-Developer-Key"] = secrets.developerKey; } } else if (mode === "project") { if (secrets.developerKey) { headers["X-Developer-Key"] = secrets.developerKey; } if (secrets.projectId) { headers["X-Project-ID"] = secrets.projectId; } if (secrets.projectKey) { headers["X-API-Key"] = secrets.projectKey; } } return headers;}
Developer keys and API keys changed from dk_ prefix to ak_ prefix in v1.5.0. The format is ak_ + 32 URL-safe characters generated by secrets.token_urlsafe(32).
Provisioning bundles are only used for developer registration in console/operator modes. End users do not receive provisioning data. In project mode (Starter Kit), developer registration is disabled and redirects to Cloud Admin.
After developer registration, a provisioning bundle is stored temporarily in an httpOnly cookie:
interface ProvisioningData { project_id: string; // UUID of auto-created default project developer_key: string; // Full developer key (ak_...) api_key: string; // Full API key for project (ak_...)}
Storage Implementation:
// lib/provisioning-store.tsexport async function storeProvisioningBundle( bundle: ProvisioningData): Promise<void> { // Validate all fields present if (!bundle.project_id || !bundle.api_key || !bundle.developer_key) { throw new Error("Invalid bundle: missing fields"); } // Serialize with timestamp const storedBundle = { ...bundle, recorded_at: new Date().toISOString(), }; // Store in httpOnly cookie const cookieStore = await cookies(); const useSecure = await shouldUseSecureCookies(); cookieStore.set("devkit4ai-provisioning", JSON.stringify(storedBundle), { httpOnly: true, secure: useSecure, sameSite: "lax", path: "/", maxAge: 60 * 60 * 24, // 24 hours });}
Cookie Security:
Name: devkit4ai-provisioning
Expiry: 24 hours (86400 seconds)
Flags: httpOnly (not accessible to JavaScript), secure (HTTPS only), sameSite=lax
Path: / (accessible to all routes)
Display on Success Page:The /register/developer/success page shows these credentials once using consumeProvisioningBundle():
// app/(auth)/register/developer/success/page.tsxexport default async function DeveloperSuccessPage() { const bundle = await consumeProvisioningBundle(); if (!bundle) { redirect("/login"); } return ( <div> <h1>Registration Successful!</h1> <p>Copy these credentials - they won't be shown again:</p> <div> <label>Project ID:</label> <code>{bundle.project_id}</code> </div> <div> <label>Developer Key:</label> <code>{bundle.developer_key}</code> </div> <div> <label>API Key:</label> <code>{bundle.api_key}</code> </div> </div> );}
JWT tokens are stateless, so no backend invalidation is required. Clearing cookies on client side immediately revokes access. The backend cannot track or revoke issued tokens before expiration.
Never expose JWT tokens or API keys in client-side JavaScript. Always use httpOnly cookies for token storage and server-side environment variables for API keys.
Email verification infrastructure is in place with is_active flags and EmailVerificationWasRequested events, but verification emails are not yet sent. This feature is planned for a future release.
Current Implementation:
All new users registered with is_active: false
EmailVerificationWasRequested event emitted with 24h token
Verification tokens stored in users table
Manual activation required (update is_active in database)
Planned Email Verification Flow:
User registers → Backend emits EmailVerificationWasRequested event
Email service (future) sends verification email with link
User clicks link → GET /api/v1/auth/verify-email?token=…