Overview
Hazel Chat uses WorkOS for authentication, supporting both session-based authentication (web/desktop) and bearer token authentication (bots). All authenticated requests automatically provide user context to RPC handlers.
Authentication Flow
Web/Desktop Authentication
The web and desktop applications use WorkOS session cookies:
User initiates OAuth flow through WorkOS
WorkOS redirects back with authorization code
Backend exchanges code for session token
Session cookie is set (httpOnly, secure)
Future requests include session cookie automatically
// Frontend: Session cookie is automatically included
const result = yield * client [ "message.create" ]({
channelId: "channel-id" ,
content: "Hello!"
})
// CurrentUser is automatically available in the handler
Bot Authentication
Bots authenticate using bearer tokens in the Authorization header:
import { makeBotRpcClient } from "@hazel/bot-sdk"
const client = yield * makeBotRpcClient ({
backendUrl: "https://api.hazel.sh" ,
botToken: "bot_your_token_here"
})
// Bearer token automatically added to all requests
const result = yield * client [ "message.create" ]({ ... })
Never expose bot tokens in client-side code or public repositories. Store them securely as environment variables.
WorkOS Configuration
The backend requires the following WorkOS environment variables:
# WorkOS API credentials
WORKOS_API_KEY = sk_test_your_api_key
WORKOS_CLIENT_ID = client_your_client_id
WORKOS_WEBHOOK_SECRET = your_webhook_secret
# OAuth redirect
WORKOS_REDIRECT_URI = http://localhost:3003/auth/callback
# Session cookie domain
WORKOS_COOKIE_DOMAIN = localhost
In production, update WORKOS_REDIRECT_URI to your production API URL and WORKOS_COOKIE_DOMAIN to your domain.
Authentication Middleware
RPC methods use the AuthMiddleware to automatically authenticate requests:
import { Rpc } from "@effect/rpc"
import { AuthMiddleware } from "@hazel/domain/rpc"
// RPC definition with authentication
Rpc . make ( "message.create" , {
payload: MessageInsert ,
success: MessageResponse ,
error: Schema . Union ( UnauthorizedError , InternalServerError )
}). middleware ( AuthMiddleware )
How AuthMiddleware Works
Extracts credentials : Reads session cookie or Authorization header
Verifies token : Validates JWT signature against WorkOS JWKS
Fetches user : Loads user from database (or creates if first login)
Provides context : Makes CurrentUser available to handler
// In RPC handler - CurrentUser is automatically available
export const MessageRpcLive = MessageRpcs . toLayer (
Effect . gen ( function* () {
return {
"message.create" : ( payload ) =>
Effect . gen ( function* () {
const user = yield * CurrentUser . Context
// user.id, user.email, user.role automatically available
const message = yield * MessageRepo . insert ({
... payload ,
authorId: user . id // Automatically set from auth
})
return { data: message , transactionId: "..." }
})
}
})
)
CurrentUser Context
Authenticated requests provide a CurrentUser object with the following fields:
interface CurrentUser {
id : UserId // Internal user UUID
email : string // User email
firstName : string // First name
lastName : string // Last name
role : "owner" | "admin" | "member" // Organization role
organizationId ?: OrganizationId // Current org (if impersonating)
avatarUrl ?: string // Profile picture URL
isOnboarded : boolean // Onboarding status
timezone : string | null // User timezone
settings : UserSettings | null // User preferences
}
Session Management
Session Verification
The backend verifies sessions by:
Extracting JWT from cookie/header
Verifying signature using WorkOS JWKS endpoint
Checking expiration
Syncing user data from WorkOS
// Backend authentication service
export class BackendAuth extends Effect . Service < BackendAuth >()(
"@hazel/auth/BackendAuth" ,
{
effect: Effect . gen ( function* () {
const authenticateWithBearer = ( bearerToken : string , userRepo : UserRepoLike ) =>
Effect . gen ( function* () {
// Verify JWT with WorkOS JWKS
const jwks = createRemoteJWKSet (
new URL ( `https://api.workos.com/sso/jwks/ ${ clientId } ` )
)
const { payload } = yield * jwtVerify ( bearerToken , jwks , {
issuer: "https://api.workos.com"
})
// Fetch or create user
const user = yield * syncUserFromWorkOS (
userRepo ,
payload . sub ,
payload . email ,
payload . firstName ,
payload . lastName ,
payload . profilePictureUrl
)
return new CurrentUser ( user )
})
return { authenticateWithBearer }
})
}
)
Session Caching
To improve performance, authenticated sessions are cached in Redis:
Cache key : Session token hash
TTL : Matches JWT expiration
Invalidation : Automatic on token expiry
Session caching reduces database queries and WorkOS API calls, improving response times for authenticated requests.
Authentication Errors
The authentication system returns typed errors for different failure scenarios:
Session Not Provided (401)
{
_tag : "SessionNotProvidedError" ,
message : "No session cookie or Authorization header" ,
detail : "Authentication required"
}
Invalid Bearer Token (401)
{
_tag : "InvalidBearerTokenError" ,
message : "JWT verification failed" ,
detail : "Invalid signature or expired token"
}
Session Expired (401)
{
_tag : "SessionExpiredError" ,
message : "Session has expired" ,
detail : "Please re-authenticate"
}
Session Load Error (503)
{
_tag : "SessionLoadError" ,
message : "Failed to load session" ,
detail : "Database connection error"
}
Unauthorized (401)
Generic authorization failure:
{
_tag : "UnauthorizedError" ,
message : "Permission denied" ,
detail : "You don't have access to this resource"
}
401 errors require re-authentication. 503 errors (SessionLoadError) can be retried.
Example: Handling Authentication
Frontend Error Handling
import { Effect } from "effect"
import { HazelRpcClient } from "~/lib/services/common/rpc-atom-client"
const createMessage = Effect . gen ( function* () {
const client = yield * HazelRpcClient
return yield * client [ "message.create" ]({
channelId: "channel-id" ,
content: "Hello!"
})
}). pipe (
Effect . catchTags ({
UnauthorizedError : ( error ) =>
Effect . gen ( function* () {
console . error ( "Not authorized:" , error . message )
// Redirect to login
yield * Effect . sync (() => window . location . href = "/auth/login" )
}),
SessionExpiredError : ( error ) =>
Effect . gen ( function* () {
console . warn ( "Session expired, refreshing..." )
// Trigger session refresh
yield * refreshSession ()
}),
SessionLoadError : ( error ) =>
Effect . gen ( function* () {
console . error ( "Session load failed, retrying..." )
// Retry with exponential backoff
yield * Effect . sleep ( "1 second" )
return yield * createMessage
})
})
)
Bot Error Handling
import { Effect } from "effect"
import { makeBotRpcClient } from "@hazel/bot-sdk"
const program = Effect . gen ( function* () {
const client = yield * makeBotRpcClient ({
backendUrl: process . env . BACKEND_URL ! ,
botToken: process . env . BOT_TOKEN !
})
return yield * client [ "message.create" ]({
channelId: "channel-id" ,
content: "Bot message"
})
}). pipe (
Effect . catchTag ( "InvalidBearerTokenError" , ( error ) =>
Effect . gen ( function* () {
console . error ( "Invalid bot token:" , error . message )
// Log and exit - bot token is incorrect
yield * Effect . fail ( new Error ( "Bot authentication failed" ))
})
),
Effect . retry ({ times: 3 , schedule: "exponential" })
)
Security Best Practices
Never expose authentication credentials:
Don’t commit .env files
Don’t log session tokens or bearer tokens
Use secure, httpOnly cookies for sessions
Rotate bot tokens regularly
Session Cookie Security
The backend sets secure session cookies with:
{
httpOnly : true , // Not accessible via JavaScript
secure : true , // HTTPS only (production)
sameSite : "lax" , // CSRF protection
domain : process . env . WORKOS_COOKIE_DOMAIN ,
maxAge : 7 * 24 * 60 * 60 * 1000 // 7 days
}
Bot Token Security
Store bot tokens in environment variables
Use different tokens for dev/staging/production
Implement token rotation
Monitor bot token usage
Revoke compromised tokens immediately
Next Steps
Error Handling Learn about all error types and how to handle them
API Introduction Return to API overview and architecture