_private/qwestly-hire-docs/features/authentication-and-signup.md
Table of Contents
Authentication and Signup Flow
This document describes the authentication and signup flow for Qwestly Hire, including user signup, authorization checks, and access control.
Overview
Qwestly Hire uses Auth0 for authentication with a custom authorization system that:
- Requires users to sign up before accessing the application
- Syncs user data from Auth0 to our MongoDB database
- Checks authorization status on every login
- Blocks unauthorized users from accessing protected routes
- Supports both password and Google social signup
- Routes hiring managers through hire onboarding (
/onboarding/*) untilonboarding_completed_atis set; seehire-onboarding.md
Architecture
Components
- Auth0 - Handles user authentication (login, logout, session management)
- User Model - MongoDB collection storing user data and permissions
- Signup Service - Manages incomplete signups and user syncing
- Authorization Endpoint - Checks user access and redeems signup tokens
- Middleware - Protects routes and enforces authorization
- Site Token - JWT token stored in session containing authorization status
- App base URL - Canonical origin for Auth0 (
appBaseUrl) and server-side calls to/api/auth/authorized. Resolved at startup fromAPP_BASE_URL, orVERCEL_BRANCH_URLon Vercel preview deployments whenAPP_BASE_URLis unset (seesrc/config/server.tsandsrc/lib/normalize-app-base-url.ts).
User Model
Users are stored in MongoDB with the following structure:
{
user_id: string; // Auth0 user ID (unique)
name: string; // Cached from Auth0
email: string; // Cached from Auth0
work_email_verified: boolean; // Email verification status
company_ids: string[]; // Array of company IDs user has access to
parent_org_ids: string[]; // Array of parent org IDs user has access to
desired_roles: string[]; // Array of desired roles (optional)
onboarding_completed_at?: Date | null; // Set when hire onboarding finishes (see hire-onboarding.md)
onboarding_progress?: { // Wizard position for /onboarding (optional)
current_step: 'company' | 'roles' | 'job' | 'hm-notes' | 'review';
completed_steps?: string[];
draft_job_id?: string;
};
created_at: Date;
updated_at: Date;
}
Permission Model
- Users can be assigned to multiple companies and/or parent organizations
- If a user has
parent_org_idsbut nocompany_ids, they get access to all companies within those parent orgs - Users must have at least one company or parent org assigned (enforced at application level, not database level)
- During signup, users are created with empty arrays; admins must assign companies/parent orgs later
Signup Flow
Password Signup
- User visits
/signuppage - User fills out form with name, email, and password
- Form is submitted to
POST /api/auth/signup/password - API route:
- Validates input (email format, password strength)
- Checks if user already exists
- Creates user in Auth0 via Management API
- Syncs user data to MongoDB (
syncUserFromAuth0) without settingonboarding_completed_at - Returns success with
redirectTo: /auth/login?returnTo=/(or encoded/) so post-login routing can send the user into hire onboarding as needed
- User logs in;
/and/dashboardredirect into/onboarding/*until hire onboarding is finished (seehire-onboarding.md)
Google Social Signup
- User visits
/signuppage - User optionally provides email/name before clicking "Sign in with Google"
- If email provided:
POST /api/auth/signup/initiate-socialis called- Creates incomplete signup record with
signup_token - Token is stored in:
localStorage(key:signup_token)- Cookie (name:
signup_token, expires in 24 hours)
- User is redirected to Auth0 Google login (return URL may still use legacy
/signup/complete, which redirects to/onboarding/company) - After Google authentication, Auth0 may land on
/signup/complete; that route immediately redirects to/onboarding/company beforeSessionSavedhook runs:- Checks for
signup_tokencookie - If found, calls
POST /api/auth/authorizedwith token - Endpoint redeems token, syncs user data, and checks authorization
- Cookie is deleted
- Checks for
- User continues in hire onboarding at
/onboarding/*(company, roles, first job, optional HM notes, review). Legacy/signup/completeis only a redirect to/onboarding/company. - Optional: social completion endpoints (
POST /api/auth/signup/complete-social,POST /api/auth/signup/mark-completed) may persistcompany_ids/desired_roleswhen provided; identity-only paths no longer imply โsignup completeโ for app access. siteTokenis generated with authorization andhire_onboarding_complete(derived fromonboarding_completed_at) and stored in session
Hire onboarding completion (app access)
Full access to the main app (e.g. /dashboard) requires onboarding_completed_at to be set on the user document. That happens at the end of the hire onboarding wizard (see hire-onboarding.md), not via has_completed_signup (removed from the hire path).
Flow (summary):
- User authenticates via Auth0 (password or social)
- User is synced in MongoDB and may have empty
company_idsuntil they pick a company in/onboarding/company - Wizard persists step state via
POST /api/onboarding/progressand related onboarding APIs - When the user publishes their first job from onboarding,
onboarding_completed_atis set hire_onboarding_completein the authorized response / site token becomes true; dashboard stops redirecting into the wizard
Notes:
@qwestly.comprimary emails are authorized as admins but still follow the sameonboarding_completed_atgate for โhire onboarding completeโ unless product changes that rule- The proxy (
src/proxy.ts) refreshesauthorized/hire_onboarding_completeon the site token but does not redirect incomplete users into/onboarding(the dashboard server page does)
Account Request Flow (Cross-App Users)
A unique scenario occurs when a user has an Auth0 account (e.g., from the candidate app) but doesn't have a database record in the hire app. This prevents infinite redirect loops and provides a clear path for account creation.
Problem:
- User authenticates via Auth0 (valid session exists)
- User tries to access dashboard (
/) or/dashboard - Dashboard checks for
dbUserrecord โ not found - Without an intermediary step, naive redirects between โfinish signupโ and โhomeโ can loop
Solution:
The /account-request page acts as an intermediary step that:
- Confirms the user wants to create an account in this application
- Prevents automatic redirects that could cause loops
- Provides clear messaging about what account creation entails
Flow:
- User with Auth0 account (but no
dbUserrecord) tries to access dashboard - Dashboard checks for
dbUserโ not found - User is redirected to
/account-request /account-requestpage:- Shows user's logged-in email address
- Explains they don't have an account in this application yet
- Asks if they want to create an account
- Provides two options:
- Cancel: Logs out and returns to signup page
- Create Account: Navigates to
/onboarding/companyto start hire onboarding
- After
dbUserexists,/dashboardstill redirects into/onboarding/*untilonboarding_completed_atis set
Key Differences from Normal Signup:
- Normal signup: User starts at
/signupโ creates Auth0 account โ lands in hire onboarding when appropriate - Account request: User already has Auth0 account โ confirms account creation โ
/onboarding/company - Legacy
/signup/completeonly redirects to/onboarding/company
Middleware Protection:
/account-requestis excluded from the โunauthorizedโ redirect (similar to/signuproutes)- Allows authenticated users without a hire
dbUserrow to reach account request / onboarding
Implementation Details:
- Dashboard (
src/app/dashboard/page.tsx): Redirects to/account-requestwhendbUserdoesn't exist; redirects into/onboarding/*when hire onboarding is incomplete - Account Request Page (
src/app/account-request/page.tsx/client.tsx):Create Accountโ/onboarding/company - Signup Complete (
src/app/signup/complete/page.tsx): redirect to/onboarding/company - Proxy (
src/proxy.ts): Does not redirect incomplete hire onboarding to/onboarding(dashboard owns that); still refresheshire_onboarding_completeon the site token when needed
Signup Token Flow
The signup_token mechanism allows us to:
- Link the Auth0 authentication to our signup process
- Sync user data from Auth0 to our database
- Handle the case where user clicks Google signup before providing email
Token Storage:
- Created when user initiates social signup (if email provided)
- Stored in cookie and localStorage for cross-browser detection
- Expires after 24 hours
- Redeemed in
beforeSessionSavedhook after Auth0 login
Token Lookup:
- When completing social signup, the system tries to find incomplete signup by:
- Token (if provided)
- User ID (from Auth0 session)
- Email (from Auth0 session)
- This handles cases where token is lost but user is authenticated
Authorization System
Authorization Check
Authorization is checked in two scenarios:
-
During Signup (with signup_token):
POST /api/auth/authorizedis called withsignup_token- Token is redeemed (incomplete signup is marked complete)
- User data is synced to database
- Authorization is checked
-
On Every Login (without signup_token):
GET /api/auth/authorizedis called with Bearer token- Authorization is checked based on user_id
Authorization Rules
A user is authorized if:
- Email Whitelist: User's email ends with
@qwestly.com(automatic authorization) - Database Check: User exists in the
userscollection (user_id matches)
Site Token
The siteToken is a JWT token stored in session.user.siteToken containing:
{
userId: string; // Auth0 user ID
userRole?: string; // "admin" if in ADMIN_EMAILS env var or @qwestly.com email
authorized?: boolean; // Whether user has access to the app
hire_onboarding_complete?: boolean; // True when `onboarding_completed_at` is set on the user
onboarding_completed_at?: string | null; // ISO timestamp from DB (optional on token)
}
Token Generation:
- Generated in
beforeSessionSavedhook after checking authorization - Uses
joselibrary for signing (HS256 algorithm) - Secret:
JWT_SITE_TOKEN_SECRET(fallback:JWT_SECRET,AUTH0_SECRET) - Stored in session for subsequent requests
Token Usage:
- Proxy / middleware decode token to check
authorizedand refreshhire_onboarding_completeviacheckAndUpdateAuthorizationwhen needed - Can be decoded in API routes/pages to check user role and authorization
- Prevents unauthorized users from accessing protected routes
Middleware Protection
The proxy (src/proxy.ts) protects all routes except:
/auth/*- Auth0 routes (login, logout, callback)/signup/*- Signup pages/account-request- Account request page (for cross-app users)/unauthorized- Unauthorized access page/api/auth/signup/*- Signup API routes/api/auth/authorized- Authorization check endpoint
The proxy uses a config matcher to exclude static files, images, and metadata files from processing.
Middleware Flow
- Check if route is public โ allow through
- Get Auth0 session
- If no session โ redirect to
/auth/login - Decode
siteTokenfrom session forauthorized/hire_onboarding_complete - If token is missing or incomplete, proxy may call
checkAndUpdateAuthorization()to refreshauthorizedandhire_onboarding_complete(seesrc/services/auth0.ts) - Check
authorizedproperty:- If
falseโ redirect to/unauthorized(unless path is exempt, e.g./account-request) - If
trueโ allow the request through the proxy
- If
- Hire onboarding gating for the main app is enforced on
/dashboard(server redirect), not via a proxy redirect to/onboarding - For
/adminroutes: additional check for@qwestly.comemail
Note: On first request after login, siteToken may not exist yet (it's generated in beforeSessionSaved). In this case, the proxy checks authorization via API as a fallback and updates the session if needed.
Fallback Authorization Check:
- If
siteTokenis missing or shows unauthorized, proxy callscheckAndUpdateAuthorization()API - This fetches latest authorization status and regenerates
siteTokenif needed - Prevents issues where DB-backed flags changed but session hasn't refreshed yet
Unauthorized Access
When a user is authenticated but not authorized (doesn't have access to the application), they are redirected to /unauthorized. This page:
- Shows a clear "Access Unauthorized" message
- Displays the user's email address
- Provides options to logout or sign up
- Explains that they should contact an administrator if they believe they should have access
This is different from the signup page, which is for users who haven't created an account yet. The unauthorized page is for users who have authenticated but don't have access permissions.
API Endpoints
/api/auth/signup/password
Creates a password-based account.
Request:
{
"name": "John Doe",
"email": "john@example.com",
"password": "securepassword123"
}
Response:
{
"success": true,
"message": "Account created successfully. Please log in to complete your signup.",
"redirectTo": "/auth/login?returnTo=%2F"
}
/api/auth/signup/initiate-social
Initiates social signup by creating an incomplete signup record.
Request:
{
"email": "john@example.com",
"name": "John Doe" // optional
}
Response:
{
"success": true,
"token": "abc123..." // signup_token
}
/api/auth/signup/complete-social
Completes social signup after authentication. Requires user to be authenticated.
Request:
{
"token": "abc123...", // signup_token (optional, can find by userId/email)
"company_ids": ["company1"], // Optional: assign now, or leave empty and use /onboarding
"desired_roles": ["role1"] // Optional: array of desired roles
}
Response:
{
"success": true,
"message": "Signup completed successfully",
"redirectTo": "/"
}
Behavior:
- Finds incomplete signup by token, userId, or email (in that order)
- If no incomplete signup found but user exists, returns success
- If no incomplete signup and user doesn't exist, creates incomplete signup or syncs user directly
- When
company_idsare provided, persists them (and optionaldesired_roles); hire onboarding completion is still driven byonboarding_completed_at, not this endpoint alone - Syncs user data from Auth0 to database
/api/auth/signup/mark-completed
Updates the user record when a client submits company/role selection, or performs an identity-only sync when company_ids is empty.
Request:
{
"company_ids": ["company1"], // Optional: empty โ identity sync + redirect home (hire onboarding still applies)
"desired_roles": ["role1"] // Optional: array of desired roles
}
Response (with company_ids):
{
"success": true,
"message": "Signup marked as completed"
}
Response (identity-only, empty company_ids):
{
"success": true,
"message": "Identity synced",
"redirectTo": "/"
}
Behavior:
- Requires user to be authenticated
- Creates user record if it doesn't exist (when needed)
- With non-empty
company_ids: assignscompany_ids/ optionaldesired_roles(does not setonboarding_completed_at) - With empty
company_ids: may sync identity only and returnredirectTo: /; user still completes hire onboarding in/onboarding/*
/api/auth/signup/check-incomplete
Checks if current authenticated user has an incomplete signup.
Response:
{
"hasIncompleteSignup": true
}
/api/auth/authorized
Checks user authorization and redeems signup tokens.
GET - Check authorization via Bearer token:
Authorization: Bearer <authCheckToken>
POST - Redeem signup token and check authorization:
{
"signup_token": "abc123...", // Optional: if provided, redeems token and syncs user
"userId": "auth0|123", // Required
"email": "john@example.com" // Required
}
Response (POST example):
{
"authorized": true,
"hire_onboarding_complete": false,
"onboarding_completed_at": null,
"success": true,
"admin": false
}
Behavior:
- If
signup_tokenprovided: redeems token, completes incomplete signup, syncs user - If no
signup_token: checks if user exists, syncs from Auth0 if missing - Returns
authorized,hire_onboarding_complete(fromonboarding_completed_at),onboarding_completed_at, and optionaladmin
User Data Syncing
User data is synced from Auth0 to MongoDB in the following scenarios:
- Password Signup: Immediately after Auth0 user creation (no
onboarding_completed_atuntil hire onboarding finishes) - Social Signup: When
signup_tokenis redeemed inbeforeSessionSavedvia/api/auth/authorized - Complete Social Signup: When
/api/auth/signup/complete-socialis called (may setcompany_ids/desired_roleswhen provided) - Mark Completed: When
/api/auth/signup/mark-completedis called (company assignment and/or identity-only sync) - Manual Sync: Can be called via
syncUserFromAuth0()function
The sync process:
- Checks if user exists in database
- If exists: Updates name, email, and work_email_verified
- If not exists: Creates new user record with empty
company_idsandparent_org_idsuntil onboarding assigns them
Note: User sync requires Node.js runtime and cannot run in Edge Runtime. This is why it's handled via API endpoints rather than directly in beforeSessionSaved.
Environment Variables
Required environment variables:
# Public origin of this app (must match the URL users use; required for Auth0 callbacks and internal fetches).
# Local dev (this repo uses port 3001):
APP_BASE_URL=http://localhost:3001
# On Vercel preview deployments, VERCEL_BRANCH_URL is injected automatically and is used when APP_BASE_URL is not set.
# Auth0 Configuration
AUTH0_DOMAIN=auth.qwestly.com
AUTH0_API_CLIENT_ID=your_client_id
AUTH0_API_CLIENT_SECRET=your_client_secret
AUTH0_SECRET=your_auth0_secret
# Site token JWT secret (standardized across apps)
JWT_SITE_TOKEN_SECRET=your_jwt_site_token_secret
# Admin Emails (comma-separated)
ADMIN_EMAILS=admin1@qwestly.com,admin2@qwestly.com
# Vercel Protection Bypass (for internal API calls)
VERCEL_AUTOMATION_BYPASS_SECRET=your_bypass_secret
# MongoDB
MONGODB_URI=your_mongodb_connection_string
If neither APP_BASE_URL nor VERCEL_BRANCH_URL is set, the app fails at startup when server config loads (Auth0 and any code importing appBaseUrl from @/config/server).
Security Considerations
- Signup Tokens: Expire after 24 hours, single-use (marked as completed after redemption)
- Site Tokens: Signed with HS256, contain authorization status to prevent unauthorized access
- Email Whitelist:
@qwestly.comprimary emails are treated as authorized/admin perisPrimaryQwestlyEmail; hire onboarding completion still followsonboarding_completed_atunless product policy changes - Middleware Protection: All routes protected by default except public routes
- Edge Runtime: Authorization checks use fetch requests (Edge-compatible) rather than direct database access
Troubleshooting
User Can't Access App After Signup
- Check if user exists in
userscollection - Verify
siteTokenis present in session (checksession.user.siteToken) - Decode
siteTokento checkauthorizedandhire_onboarding_complete - Check
User.onboarding_completed_atin MongoDB (source of truth for hire onboarding) - Verify user has at least one company or parent org if they should be past
/onboarding/company - Check dashboard / onboarding redirect logs (
resolveOnboardingRedirectPath) vs proxy logs forauthorized
Signup Token Not Redeemed
- Check if
signup_tokencookie exists before login - Verify cookie is being read in
beforeSessionSaved - Check
/api/auth/authorizedendpoint logs for errors - Ensure
VERCEL_AUTOMATION_BYPASS_SECRETis set if using Vercel
431 Error (Request Header Fields Too Large)
- Clear browser cookies
- Check for large Auth0 session cookies
- Verify proxy isn't adding unnecessary headers
- Check if
siteTokenis getting too large
Related Files
docs/features/hire-onboarding.md- Hire onboarding wizard, APIs, and completion gatesrc/config/server.ts- CanonicalappBaseUrlfromAPP_BASE_URLorVERCEL_BRANCH_URLsrc/lib/normalize-app-base-url.ts- Normalizes env values tohttps:///http://originssrc/lib/hire-onboarding/state.ts-hasCompletedHireOnboarding,resolveOnboardingRedirectPath, step constantssrc/services/auth0.ts- SharedAuth0Clientinstance,beforeSessionSaved, and authorization refresh helperssrc/services/signup.service.ts- Signup token management and user syncingsrc/services/token.ts- JWT token encoding/decodingsrc/models/User.ts- User Mongoose modelsrc/proxy.ts- Route protection and authorization checkssrc/app/api/auth/authorized/route.ts- Authorization endpointsrc/app/api/auth/signup/[[...slug]]/route.ts- Signup API routes (password, initiate-social, complete-social, mark-completed, check-incomplete)src/app/api/onboarding/[[...slug]]/route.ts- Hire onboarding APIs (progress,company,publish)src/app/signup/page.tsx- Signup pagesrc/app/signup/complete/page.tsx- Legacy URL; redirects to/onboarding/companysrc/app/onboarding/*- Hire onboarding UIsrc/app/account-request/page.tsx- Account request page (for users with Auth0 accounts but no dbUser record)src/app/account-request/client.tsx- Account request client componentsrc/app/unauthorized/page.tsx- Unauthorized access page