Skip to main content
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:
1

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`
2

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 }
})
3

User authenticates with WorkOS

WorkOS handles the authentication UI, supporting:
  • Email + password
  • OAuth providers (Google, GitHub, Microsoft)
  • Enterprise SSO (SAML, OIDC)
  • Magic links
4

WorkOS redirects back to callback

After successful authentication, WorkOS redirects to your configured callback URL:
http://localhost:3003/auth/callback?code=CODE&state=STATE
5

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
})
6

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.
7

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:
RolePermissionsUse Case
OwnerFull access, can delete organizationOrganization creator, single owner
AdminManage members, settings, billingTeam administrators
MemberStandard access to channels and featuresRegular team members

Creating Roles in WorkOS

You must create these roles in your WorkOS dashboard:
1

Navigate to Roles

Go to Roles in your WorkOS dashboard
2

Create Owner role

  • Role Slug: owner
  • Name: Owner
  • Description: Full access, can delete organization
3

Create Admin role

  • Role Slug: admin
  • Name: Admin
  • Description: Can manage members and settings
4

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:
apps/backend/.env
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

Build docs developers (and LLMs) love