Hazel Chat uses WorkOS AuthKit for secure, enterprise-ready authentication. This guide covers the authentication flow, session management, and role-based access control.
Overview
WorkOS AuthKit provides:
Multi-provider authentication - Email/password, Google, GitHub, Microsoft, and more
Enterprise SSO - SAML and OIDC support for organizations
Session management - Secure JWT-based sessions with automatic refresh
Organization management - Multi-tenant support with role-based access
Security - Built-in CSRF protection, secure cookie handling, and MFA support
Authentication Flow
Hazel Chat implements a server-side authentication flow with JWT bearer tokens:
User initiates login
The frontend redirects to the backend /auth/login endpoint with optional organizationId and returnTo parameters. // Frontend login redirect
window . location . href = ` ${ BACKEND_URL } /auth/login?returnTo=/channels`
Backend redirects to WorkOS
The backend generates a WorkOS authorization URL and redirects the user: apps/backend/src/routes/auth.http.ts
const authorizationUrl = client . userManagement . getAuthorizationUrl ({
provider: "authkit" ,
clientId ,
redirectUri ,
state ,
screenHint: "sign-in"
})
return HttpServerResponse . empty ({
status: 302 ,
headers: { Location: authorizationUrl }
})
User authenticates with WorkOS
WorkOS handles the authentication UI, supporting:
Email + password
OAuth providers (Google, GitHub, Microsoft)
Enterprise SSO (SAML, OIDC)
Magic links
WorkOS redirects back to callback
After successful authentication, WorkOS redirects to your configured callback URL: http://localhost:3003/auth/callback?code=CODE&state=STATE
Backend exchanges code for tokens
The backend exchanges the authorization code for access and refresh tokens: const { user , accessToken , refreshToken } = await client . userManagement . authenticateWithCode ({
code ,
clientId
})
User sync to database
The backend syncs the WorkOS user to the local database using the BackendAuth service: packages/auth/src/consumers/backend-auth.ts
const syncUserFromWorkOS = ( userRepo , workOsUserId , email , firstName , lastName , avatarUrl ) =>
Effect . gen ( function* () {
const userOption = yield * userRepo . findByExternalId ( workOsUserId )
const user = yield * Option . match ( userOption , {
onNone : () => userRepo . upsertByExternalId ({
externalId: workOsUserId ,
email ,
firstName: firstName || "" ,
lastName: lastName || "" ,
avatarUrl: normalizeAvatarUrl ( avatarUrl ),
userType: "user" ,
isOnboarded: false
}),
onSome : ( existingUser ) => {
// Update user if OAuth provides new data
// Preserves custom avatars while fixing sync issues
}
})
return user
})
User sync is idempotent - it safely handles both new users and existing users, updating only missing fields like names and avatars.
Session tokens returned to client
The backend returns access and refresh tokens to the frontend: {
"accessToken" : "eyJhbGci..." ,
"refreshToken" : "eyJhbGci..."
}
The frontend stores these tokens and includes the access token in all API requests.
Session Management
JWT Verification
The backend verifies JWT tokens on every authenticated request using the BackendAuth service:
packages/auth/src/consumers/backend-auth.ts
const authenticateWithBearer = ( bearerToken : string , userRepo : UserRepoLike ) =>
Effect . gen ( function* () {
// Create JWKS client for signature verification
const jwks = createRemoteJWKSet (
new URL ( `https://api.workos.com/sso/jwks/ ${ clientId } ` )
)
// Verify JWT with both supported issuers
const { payload } = yield * verifyWithIssuer ( "https://api.workos.com" ). pipe (
Effect . orElse (() =>
verifyWithIssuer ( `https://api.workos.com/user_management/ ${ clientId } ` )
)
)
// Extract user ID from token
const workOsUserId = payload . sub
if ( ! workOsUserId ) {
return yield * Effect . fail ( new InvalidJwtPayloadError ({}))
}
// Load user from database
const user = yield * userRepo . findByExternalId ( workOsUserId )
// Build CurrentUser context
return new CurrentUser . Schema ({
id: user . id ,
role: payload . role || "member" ,
organizationId: internalOrgId ,
email: user . email ,
firstName: user . firstName ,
lastName: user . lastName
})
})
WorkOS can issue tokens with two different issuer formats depending on the authentication flow. The verification logic handles both formats automatically.
Token Storage
The frontend stores tokens in memory (not localStorage for security):
// Store in memory
let accessToken : string | null = null
let refreshToken : string | null = null
// Include in API requests
const headers = {
Authorization: `Bearer ${ accessToken } `
}
Token Refresh
When the access token expires, the frontend automatically refreshes it:
const refreshAccessToken = async () => {
const response = await fetch ( ` ${ BACKEND_URL } /auth/refresh` , {
method: 'POST' ,
headers: { 'Content-Type' : 'application/json' },
body: JSON . stringify ({ refreshToken })
})
const { accessToken : newAccessToken } = await response . json ()
accessToken = newAccessToken
}
Role-Based Access Control
Organization Roles
Hazel Chat supports three organization roles:
Role Permissions Use Case Owner Full access, can delete organization Organization creator, single owner Admin Manage members, settings, billing Team administrators Member Standard access to channels and features Regular team members
Creating Roles in WorkOS
You must create these roles in your WorkOS dashboard:
Navigate to Roles
Go to Roles in your WorkOS dashboard
Create Owner role
Role Slug : owner
Name : Owner
Description : Full access, can delete organization
Create Admin role
Role Slug : admin
Name : Admin
Description : Can manage members and settings
Create Member role
Role Slug : member
Name : Member
Description : Standard access (default)
Role Enforcement
Roles are included in the JWT payload and enforced in the backend:
// Extract role from JWT
const currentUser = new CurrentUser . Schema ({
id: user . id ,
role: ( payload . role as "admin" | "member" | "owner" ) || "member" ,
organizationId: internalOrgId
})
// Check permissions in route handlers
if ( currentUser . role !== "owner" && currentUser . role !== "admin" ) {
return yield * Effect . fail ( new UnauthorizedError ({}))
}
CurrentUser Context
The authenticated user is available throughout the request lifecycle via Effect Context:
import { CurrentUser } from "@hazel/domain"
import { Effect } from "effect"
const myHandler = Effect . gen ( function* () {
const currentUser = yield * CurrentUser
console . log ( currentUser . id ) // User ID (UUID)
console . log ( currentUser . email ) // Email address
console . log ( currentUser . role ) // "owner" | "admin" | "member"
console . log ( currentUser . organizationId ) // Organization ID (if scoped)
console . log ( currentUser . firstName ) // First name
console . log ( currentUser . lastName ) // Last name
})
Multi-Organization Support
Organization Scoping
Users can belong to multiple organizations. The JWT includes the current organization context:
// Resolve WorkOS org ID to internal UUID
const workosOrgId = payload . org_id as string | undefined
let internalOrgId : OrganizationId | undefined = undefined
if ( workosOrgId ) {
internalOrgId = yield * workos . getOrganization ( workosOrgId ). pipe (
Effect . map (( org ) => org . externalId as OrganizationId | undefined )
)
}
Organization Switching
Users can switch organizations by re-authenticating with a different organizationId:
// Login with specific organization
window . location . href = ` ${ BACKEND_URL } /auth/login?organizationId= ${ orgId } &returnTo=/channels`
Security Best Practices
Never store tokens in localStorage - Use memory storage or secure, httpOnly cookies to prevent XSS attacks.
Environment Variables
See Environment Variables for required authentication configuration:
WORKOS_API_KEY = sk_test_your_api_key
WORKOS_CLIENT_ID = client_your_client_id
WORKOS_REDIRECT_URI = http://localhost:3003/auth/callback
WORKOS_COOKIE_DOMAIN = localhost
WORKOS_WEBHOOK_SECRET = your_webhook_secret
CSRF Protection
The authentication flow includes state parameter validation:
const validatedReturnTo = Schema . decodeSync ( RelativeUrl )( urlParams . returnTo )
const state = JSON . stringify ( AuthState . make ({ returnTo: validatedReturnTo }))
JWKS Signature Verification
All tokens are verified against WorkOS’s public keys:
const jwks = createRemoteJWKSet (
new URL ( `https://api.workos.com/sso/jwks/ ${ clientId } ` )
)
const { payload } = await jwtVerify ( bearerToken , jwks , { issuer })
Testing Authentication
The auth package provides test utilities:
import { BackendAuth } from "@hazel/auth"
import { Layer } from "effect"
// Mock successful authentication
const testLayer = BackendAuth . Test
// Custom mock user
const customLayer = BackendAuth . TestWith ({
currentUser: new CurrentUser . Schema ({
id: "custom-user-id" ,
email: "[email protected] " ,
role: "admin"
})
})
Next Steps
WorkOS Configuration Complete WorkOS setup guide
Environment Variables All authentication environment variables
Deployment Deploy authentication in production
Database Setup User storage and migrations