User Authentication and Account Management#
Overview#
Zerobyte implements a comprehensive authentication system using Better Auth (v1.5.6), a modern authentication framework introduced in v0.22.0. The system is built around a multi-tenant architecture where users belong to organizations that contain repositories, volumes, and backup schedules.
Key Features#
- Username/password authentication with secure password hashing
- Single Sign-On (SSO) with OIDC and SAML providers
- Two-factor authentication (2FA) using TOTP with backup codes
- Multi-tenant organization architecture with isolated resources
- Role-based access control at both global and organization levels
- Automatic legacy user migration for seamless upgrades
- CLI fallback commands for emergency account recovery
Database Schema#
The authentication system uses several interconnected tables to manage users, sessions, and security features.
Users Table#
The users table stores core user information:
| Field | Type | Description |
|---|---|---|
id | text | Primary key |
username | text | Unique username (normalized to lowercase) |
email | text | Unique email address |
emailVerified | boolean | Email verification status |
name | text | Display name |
displayUsername | text | Optional display version of username |
hasDownloadedResticPassword | boolean | Tracks if user downloaded backup recovery key |
twoFactorEnabled | boolean | Whether 2FA is enabled |
role | text | Global role: "admin" or "user" (default: "user") |
banned | boolean | Account suspension status |
banReason | text | Optional reason for account ban |
banExpires | timestamp | Optional expiration for temporary bans |
dateFormat | text | User's preferred date format (default: "MM/DD/YYYY") |
timeFormat | text | User's preferred time format (default: "12h") |
createdAt | timestamp | Account creation time |
updatedAt | timestamp | Last update time |
Sessions Table#
The sessions table manages active user sessions:
| Field | Type | Description |
|---|---|---|
id | text | Primary key |
userId | text | Foreign key to users table |
token | text | Unique session token |
expiresAt | timestamp | Session expiration time |
ipAddress | text | IP address of the session |
userAgent | text | Browser user agent string |
impersonatedBy | text | Admin user ID if impersonating (schema exists, feature not yet implemented) |
activeOrganizationId | text | Current organization context for the session |
Accounts Table#
The accounts table stores authentication provider details:
| Field | Type | Description |
|---|---|---|
id | text | Primary key |
accountId | text | Provider-specific account identifier |
providerId | text | Authentication provider identifier |
userId | text | Foreign key to users table |
password | text | Hashed password (for credential provider) |
accessToken | text | OAuth access token (if applicable) |
refreshToken | text | OAuth refresh token (if applicable) |
idToken | text | OAuth ID token (if applicable) |
expiresAt | timestamp | Token expiration time |
Two-Factor Authentication Table#
The two-factor authentication table stores 2FA data:
| Field | Type | Description |
|---|---|---|
id | text | Primary key |
secret | text | TOTP secret key |
backupCodes | text | Encrypted backup codes (5 codes) |
userId | text | Foreign key to users table |
SSO Provider Table#
The SSO provider table stores Single Sign-On provider configurations:
| Field | Type | Description |
|---|---|---|
id | text | Primary key |
providerId | text | Unique provider identifier (used in callback URLs) |
organizationId | text | Foreign key to organizations table |
userId | text | Foreign key to users table (admin who created the provider) |
issuer | text | Identity provider's issuer URL |
domain | text | Organization domain for provider discovery |
autoLinkMatchingEmails | boolean | Whether to automatically link accounts with matching emails |
oidcConfig | json | OIDC configuration (client ID, secret, discovery endpoint) |
samlConfig | json | SAML configuration (if applicable) |
createdAt | timestamp | Creation time |
updatedAt | timestamp | Last update time |
Organizations and Memberships#
Users access resources through organizations. See the Organizations section for details on the organization and member tables.
Authentication System#
Better Auth Configuration#
Zerobyte's authentication is powered by Better Auth (v1.5.6), configured with several plugins and database hooks. This patch release update maintains full API compatibility with previously documented features.
Plugins#
The system uses the following Better Auth plugins:
username()- Username-based authentication, configured with centralized normalization and validation logic:- Usernames are normalized by trimming whitespace and converting to lowercase.
- Allowed characters: letters, numbers, underscores, hyphens, and dots (2-30 characters).
- Validation is enforced consistently across login, registration, and backend logic.
admin()- Admin role management with default role"user"organization()- Multi-tenant organization support (users cannot create organizations themselves)sso()- Single Sign-On support for OIDC and SAML providers with organization provisioningtwoFactor()- TOTP-based 2FA with 5 encrypted backup codestanstackStartCookies()- Cookie handling for TanStack Start frameworktestUtils()- Testing utilities (loaded only whenNODE_ENV === "test")
Database Hooks#
Account Creation Hook
When an SSO account is being created, the system enforces cross-organization security through the account.create.before hook:
- Checks if the request is an SSO callback
- Calls
ssoIntegration.canLinkSsoAccount()to validate linking permission - Blocks account creation if the user has an existing credential account or belongs to a different organization without a valid invitation
- Throws
FORBIDDENerror if linking is not permitted
This database hook ensures that:
- New SSO users with valid pending invitations can link to organizations
- Existing users with credential accounts cannot bypass invitation requirements
- Cross-organization account conflicts are prevented at the database level
User Creation Hook (lines 50-99)
When a new user is created, the system automatically:
- Assigns
"admin"role to the first user (subsequent users default to"user") - Creates a personal organization named
"{name}'s Workspace" - Generates and encrypts a Restic password for backup encryption
- Creates an organization membership with
"owner"role - Rolls back the entire transaction if organization creation fails
User Deletion Hook (lines 45-49)
When a user is deleted:
- Cleans up organizations where the user is the sole owner via
authService.cleanupUserOrganizations() - Cascades deletion to all organization resources (volumes, repositories, backup schedules)
- Automatically reassigns active sessions for affected users (see Organization Cleanup below)
Session Creation Hook (lines 101-120)
When a session is created:
- Sets
activeOrganizationIdto the user's organization - Fails if the user doesn't belong to any organization
Authentication Middlewares#
Prevents additional signups when registrations are disabled. Only the first user can register unless an admin explicitly enables registrations in system settings.
Automatically migrates users from the pre-v0.22.0 authentication system to Better Auth on their first login. See Legacy User Migration for details.
SSO Integration#
SSO functionality is implemented in a separate module (app/server/modules/sso/) but integrates with the auth system through the ssoIntegration interface. The auth module calls:
ssoIntegration.beforeMiddlewares- Array of middleware functions executed before authentication, including SSO callback URL validation and provider ID validationssoIntegration.isSsoCallback()- Determines if the current request is an SSO callbackssoIntegration.onUserCreate()- Handles SSO-specific user creation logic, including invitation validationssoIntegration.resolveOrgMembership()- Resolves organization membership for SSO users, creating memberships from pending invitations when neededssoIntegration.canLinkSsoAccount()- Validates whether SSO account linking is permitted, called by the account creation database hook to enforce cross-organization security policiesssoIntegration.resolveTrustedProviders()- Dynamically determines which SSO providers are trusted for account linking based on organization settings
Login Flow#
Frontend Login Process#
The login page implements a multi-step authentication flow with support for both credential-based and SSO authentication.
Authentication Methods
Users can choose between two authentication methods:
- Username and Password (with optional 2FA)
- Single Sign-On (SSO) via registered OIDC/SAML providers
Step 1: Username and Password
Users enter their credentials:
- Username is normalized to lowercase
- Password is validated
- Calls
authClient.signIn.username()to authenticate - If 2FA is enabled, redirects to Step 2
- If 2FA is disabled, proceeds to post-login redirect
Step 1 Alternative: SSO Login
When SSO providers are configured, users see buttons for each registered provider:
- System calls
GET /auth/sso-providersto fetch available providers - Displays SSO provider buttons on the login page
- Calls
authClient.signIn.sso()with the provider ID - Redirects to the identity provider's login page
- After successful authentication, redirects back to Zerobyte
- If invite-only mode is enabled, user must have a valid invitation
Login Error Handling
The login page provides descriptive error messages for common authentication failures. When SSO authentication errors occur, they are first processed server-side through the /api/v1/auth/login-error endpoint, which maps raw error messages to standardized error codes before redirecting to the login page.
Error Mapping Process:
- SSO errors are redirected to
/api/v1/auth/login-errorwith the error message - Server calls
mapAuthErrorToCode()to convert error messages to standard codes - User is redirected to login page with standardized error code
Error Codes:
- ACCOUNT_LINK_REQUIRED: SSO sign-in was blocked because this email already belongs to another user in this instance. Contact your administrator to resolve the account conflict.
- EMAIL_NOT_VERIFIED: Identity provider did not mark the email as verified
- INVITE_REQUIRED: Access is invite-only and user lacks a valid invitation
- BANNED_USER: User account has been suspended
- SSO_LOGIN_FAILED: Generic SSO authentication failure
The login page decodes these standardized error codes and displays context-aware descriptions using decodeLoginError() and getLoginErrorDescription() from app/client/lib/sso-errors.ts.
Step 2: Two-Factor Verification (if enabled)
When 2FA is enabled, users must:
- Enter a 6-digit TOTP code from their authenticator app
- Optionally check "Trust this device for 30 days" to skip 2FA on this device
- Call
authClient.twoFactor.verifyTotp()to verify the code
Step 3: Post-Login Redirect
After successful authentication:
- If user hasn't downloaded their recovery key → redirect to
/download-recovery-key - Otherwise → redirect to
/volumes(main application)
Password Reset
Since Zerobyte is typically self-hosted, password reset is handled via CLI. The login page displays:
docker exec -it zerobyte bun run cli reset-password
Backend Authentication Middleware#
Applied to protected API routes to ensure user authentication:
- Calls
auth.api.getSession()to retrieve the current session - Validates the user belongs to an organization
- Sets request context:
c.set("user", user)andc.set("organizationId", activeOrganizationId) - Makes user and organization data available throughout the request lifecycle
Ensures the user has administrative privileges in their current organization:
- Requires
"owner"or"admin"role in the active organization - Used for organization management endpoints
Ensures the user has global administrator privileges:
- Requires global
"admin"role on the user record - Used for system-wide operations (user management, system settings)
Account Management#
User Creation#
Initial Setup: Onboarding#
- User fills in:
- Email address
- Username (2-30 characters, lowercase, may include letters, numbers, underscores, hyphens, and dots)
- Password (minimum 8 characters)
- Calls
authClient.signUp.email()to create the account - First user automatically receives the
"admin"role - System creates a personal organization and generates encryption keys
- New users are assigned default preferences:
MM/DD/YYYYfor date format and12hfor time format - User is redirected to download their backup recovery key
Admin-Created Users#
Once the system is set up, instance administrators can create additional users through the admin panel:
- Navigate to Admin → Users
- Fill in the user creation form:
- Name
- Username
- Password
- Role (
"admin"or"user")
- Calls
authClient.admin.createUser()to create the account - New users automatically inherit the organization from their creator
User Management Interface#
The user management interface (Admin → Users) provides comprehensive user administration for instance administrators (users with role === "admin"):
Search and Filtering#
- Search users by name or email
- View list with role badges and ban status indicators
- View linked authentication accounts (username/password, SSO providers) via the "Manage Accounts" button
User Actions#
Promote/Demote Role
- Toggle between
"user"and"admin"roles - Calls
authClient.admin.setRole() - Changes take effect on next login
Ban/Unban Users
- Temporarily or permanently suspend user accounts
- Calls
authClient.admin.banUser()to suspend - Calls
authClient.admin.unbanUser()to restore access - Optional ban reason and expiration date
Delete Users
- Before deletion, shows impact analysis via
getUserDeletionImpact() - Displays organizations that will be deleted (where user is sole owner)
- Shows counts of affected volumes, repositories, and backup schedules
- Requires confirmation before proceeding
- Calls
authClient.admin.removeUser()to execute deletion - Triggers cleanup of orphaned organizations via
cleanupUserOrganizations()
Organization Cleanup During User Deletion
When cleanupUserOrganizations() deletes organizations, it also handles session reassignment for affected users:
- Identifies users who are members of organizations being deleted
- Finds alternative organizations for each affected user
- Updates their sessions to use a fallback organization (or null if no alternatives exist)
- Wraps the entire operation in a database transaction for consistency
This prevents users from having invalid activeOrganizationId references in their sessions when their active organization is deleted.
Unlink SSO Accounts
- View and manage linked authentication accounts for each user
- Click the "Manage Accounts" button (key icon) to open the account management dialog
- Shows all linked accounts (credential, SSO providers) for the selected user
- Unlink SSO accounts while preserving username/password access
- Calls
DELETE /auth/admin-users/:userId/accounts/:accountId - Prevents deleting the last authentication method (409 Conflict response)
Admin User List API#
Get Admin Users (Admin Only)
GET /auth/admin-users
Returns list of users with their linked accounts:
{
"users": [
{
"id": "user-123",
"name": "Alice Smith",
"email": "alice@example.com",
"role": "admin",
"banned": false,
"accounts": [
{
"id": "acc-456",
"providerId": "credential"
},
{
"id": "acc-789",
"providerId": "acme-oidc"
}
]
}
],
"total": 10
}
Requires requireAdmin middleware.
Permissions and Roles#
Zerobyte uses a two-tier permission system: global roles for system-wide access and organization roles for resource-level access.
Global Roles#
Defined on the user record in the users table:
| Role | Permissions |
|---|---|
"admin" | Full system access: user management, system settings, access to dedicated Admin page |
"user" | Standard user access: own organization resources only |
The first user created automatically receives the "admin" role. Subsequent users default to "user" unless explicitly set to admin during creation.
Organization Roles#
Defined in the member table, these roles control access within an organization:
| Role | Permissions |
|---|---|
"owner" | Full organization control: manage members, delete organization, configure SSO |
"admin" | Organization administration: manage resources, invite members, manage organization settings |
"member" | Standard access: view and use organization resources |
Users receive the "owner" role when their personal organization is created during account setup.
Permission Levels#
Zerobyte distinguishes between two types of administrative access:
- Instance-level admin: Users with
role === "admin"can access the dedicated Admin page at/adminfor global system administration, including user management and system settings. A special "Administration" menu item appears in the sidebar for these users. - Organization-level admin: Members with
activeMember?.role === "admin"or"owner"can manage organization-specific settings via the Settings page, including member management and SSO configuration.
Organizations#
Overview#
Organizations provide multi-tenant isolation in Zerobyte. Every user must belong to at least one organization, and all resources (volumes, repositories, backup schedules) are owned by organizations, not individual users. Users can be members of multiple organizations and switch between them using the organization switcher in the sidebar.
Organization Table#
The organizations table contains:
| Field | Type | Description |
|---|---|---|
id | text | Primary key |
name | text | Organization name (e.g., "Alice's Workspace") |
slug | text | Unique URL-friendly identifier |
logo | text | Optional logo URL |
metadata | json | Stores encrypted Restic password for backups |
createdAt | timestamp | Creation time |
Member Table#
The member table links users to organizations:
| Field | Type | Description |
|---|---|---|
organizationId | text | Foreign key to organizations |
userId | text | Foreign key to users |
role | text | Member's role: "owner", "admin", or "member" |
createdAt | timestamp | Membership creation time |
The combination of organizationId and userId must be unique.
Organization Switcher#
The organization switcher appears in the sidebar footer when users belong to multiple organizations:
Features:
- Displays the active organization with its logo or initials
- Shows the total number of organizations the user has access to
- Provides a dropdown menu to switch between organizations
- Indicates the currently active organization with a "Current" label
- Calls
authClient.organization.setActive()to change the active organization
Organization Context:
Users can query their organization membership using the useOrganizationContext() hook, which returns:
organizations: List of all organizations the user belongs toactiveOrganization: The currently active organizationactiveMember: The user's membership details in the active organization
Multi-Organization Support:
- Users can be members of multiple organizations with different roles in each
- Each session maintains an
activeOrganizationIdthat determines which organization's resources are accessible - Switching organizations updates the session's active organization without requiring re-authentication
Invitations#
The invitation table stores invitations for organization access:
| Field | Type | Description |
|---|---|---|
id | text | Primary key |
organizationId | text | Target organization |
email | text | Invitee's email address |
role | text | Role to assign upon acceptance |
status | text | Invitation status (default: "pending") |
expiresAt | timestamp | Expiration time |
inviterId | text | User who sent the invitation |
Invitations are used for SSO invite-only access control. When SSO providers are configured, organization administrators can invite users by email, and those users must have an active invitation before they can sign in via SSO. See Invite-Only Access Control for details.
Organization Member Management#
Organization administrators (owners and admins) can manage members through the Settings → Organization → Members interface:
Viewing Members
- Navigate to Settings → Organization tab
- View list of all organization members with their roles
- See member details including name, email, and role
Managing Member Roles
- Promote members to admin or demote admins to member
- Cannot change the role of the organization owner
- Calls
PATCH /auth/org-members/:memberId/roleto update roles - Requires organization admin privileges
Removing Members
- Remove members from the organization (except owners)
- Removed members lose access to all organization resources
- Calls
DELETE /auth/org-members/:memberIdto remove members - Requires organization admin privileges
Single Sign-On (SSO) Authentication#
Zerobyte supports Single Sign-On with OIDC and SAML identity providers, enabling organizations to integrate with their existing authentication infrastructure.
Overview#
SSO authentication in Zerobyte operates in invite-only mode, where users must be invited before they can sign in. This provides controlled access while leveraging existing identity management systems.
Key Features:
- Support for OIDC and SAML 2.0 protocols
- Per-provider automatic account linking control
- Invite-only access control for enhanced security
- Multi-organization SSO support
- Organization provisioning with default member roles
SSO Provider Management#
Registering an SSO Provider#
Organization administrators can register SSO providers through the SSO settings UI:
- Navigate to Settings → Organization → SSO section
- Click "Register new" provider
- Fill in the provider configuration form
- Call
authClient.sso.register()to create the provider - Provider is immediately available on the login page
Configuration Form:
The SSO provider creation form collects:
- Provider ID: Unique identifier (e.g.,
acme-oidc) - Issuer URL: Identity provider's issuer URL
- Domain: Organization domain for provider discovery (e.g.,
example.com) - Client ID: OAuth/OIDC client identifier
- Client Secret: OAuth/OIDC client secret
- Discovery Endpoint: OIDC discovery URL (e.g.,
https://idp.example.com/.well-known/openid-configuration) - Link matching emails: Whether to automatically link accounts with matching email addresses
Callback URL:
The callback URL for the provider is: {BASE_URL}/api/auth/sso/callback/{providerId}
Configure this URL in your identity provider's settings.
Auto-Linking Existing Accounts#
The auto-linking feature allows SSO users to automatically access their existing Zerobyte accounts when the email address matches:
- Enabled: Users signing in via SSO with a matching email will be linked to their existing account within the same organization
- Disabled: SSO creates a separate account, even if the email matches
This setting is controlled per-provider and can be toggled at any time:
- Navigate to Settings → Organization → SSO section
- Toggle the "Auto-link existing account" switch for the provider
- Calls
PATCH /auth/sso-providers/:providerId/auto-linking
Organization-Scoped Auto-Linking:
Auto-linking is restricted to users within the same organization for security. The system enforces these rules through the canLinkSsoAccount() function, which is called by a database hook during SSO account creation:
Rules for Auto-Linking:
- Users who are already members of the SSO organization can authenticate normally with auto-linking enabled
- New SSO users (without any existing accounts) who have valid pending invitations can be auto-linked to the organization
- Existing users with credential accounts (username/password) cannot be auto-linked via SSO, even with valid pending invitations, to prevent account conflicts
- Users who already belong to a different organization (including personal organizations) must receive an explicit invitation to join the SSO-enabled organization
Implementation:
The enforcement happens at the database level through the account.create.before hook in app/server/lib/auth.ts. When an SSO account is being created:
- The hook checks if any account already exists for the user (credential or SSO)
- If a credential account exists, linking is blocked regardless of invitations
- If no account exists and a valid invitation is present, linking is allowed
- If the user belongs to a different organization without an invitation, linking is blocked
This prevents unauthorized cross-organization access through matching email addresses and prevents account conflicts where users would have both credential and SSO authentication methods from different organizations.
Security Note: Only enable auto-linking for identity providers you trust. An attacker controlling the SSO provider could gain access to existing accounts within the organization.
Listing Providers#
Get Public SSO Providers
GET /auth/sso-providers
Returns publicly visible SSO providers for the login page:
{
"providers": [
{
"providerId": "acme-oidc",
"organizationSlug": "acme-workspace"
}
]
}
Get SSO Settings (Admin Only)
GET /auth/sso-settings
Returns complete SSO configuration for the active organization:
{
"providers": [
{
"providerId": "acme-oidc",
"type": "oidc",
"issuer": "https://idp.example.com",
"domain": "example.com",
"autoLinkMatchingEmails": true,
"organizationId": "org-123"
}
],
"invitations": [
{
"id": "inv-456",
"email": "user@example.com",
"role": "member",
"status": "pending",
"expiresAt": "2024-12-31T23:59:59Z"
}
]
}
Deleting Providers#
Delete SSO Provider (Admin Only)
DELETE /auth/sso-providers/:providerId
Deletes an SSO provider and all associated accounts:
- Requires
requireAdminmiddleware - Validates provider belongs to the admin's organization
- Cascades deletion to all accounts using this provider
Invite-Only Access Control#
SSO authentication in Zerobyte requires users to be invited before they can sign in. This prevents unauthorized access even if users exist in the identity provider.
Cross-Organization Security:
Zerobyte enforces strict organization boundaries during SSO authentication through the canLinkSsoAccount() function, which is called by a database hook before creating SSO accounts:
Access Rules:
- Users already in the SSO organization (who previously accepted an invitation) can authenticate without a new invitation
- New SSO users (without any existing accounts) who have valid pending invitations can sign in and will be automatically added to the organization
- Existing users with credential accounts (username/password) cannot bypass the invitation requirement through SSO, even with valid pending invitations, to prevent account conflicts across organizations
- Users with existing memberships in other organizations (including personal workspaces) must have an explicit invitation to join an SSO-enabled organization
These rules are enforced at the database level through the account.create.before hook, which validates account linking permissions before allowing SSO account creation. This prevents users from gaining unauthorized access to organizations by using matching email addresses.
Creating Invitations#
Organization administrators can invite users through the SSO settings:
- Navigate to Settings → Organization → SSO section
- Enter the user's email address
- Select the role:
member,admin, orowner - Click "Invite"
- Call
authClient.organization.inviteMember()to create the invitation
The invitation is stored with:
- Email address (normalized to lowercase)
- Organization ID
- Role to assign upon acceptance
- Status:
pending - Expiration time (default: 7 days)
Invitation Workflow#
When a user attempts to sign in via SSO:
- User clicks SSO provider button on login page
- Redirects to identity provider for authentication
- After successful authentication, system validates invitation requirements and existing accounts
- For users already in the SSO org: If the user previously accepted an invitation to this organization, they can authenticate normally
- For new SSO users: If no user account exists and a valid invitation is present, creates the user account and organization membership
- For existing users with credential accounts: If the user already has a credential account (username/password), they cannot sign in via SSO even with a valid invitation, to prevent account conflicts across organizations
- If requirements are not met → returns
403 Forbiddenwith appropriate error message - If requirements are met → establishes session and grants access
- Invitation status changes from
pendingtoaccepted(for new memberships) - User gains access to the organization
Important: Users should complete SSO sign-in before creating any credential accounts. Once a credential account exists, SSO sign-in is blocked to prevent cross-organization account conflicts.
Managing Invitations#
Create Invitation
Organization administrators can create invitations through the SSO settings UI:
- Navigate to Settings → Organization → SSO section
- Enter the user's email address
- Select the role:
member,admin, orowner - Click "Invite"
- Call
authClient.organization.inviteMember()to create the invitation
The invitation is stored with:
- Email address (normalized to lowercase)
- Organization ID
- Role to assign upon acceptance
- Status:
pending - Expiration time (default: 7 days)
Cancel Invitation
Active (pending) invitations can be cancelled:
- Click the cancel button (ban icon) on the invitation row
- Call
authClient.organization.cancelInvitation() - Prevents the invited user from signing in
Delete Invitation
Expired or accepted invitations can be deleted:
- Click the delete button (trash icon) on non-pending invitations
- Calls
DELETE /auth/sso-invitations/:invitationId - Requires admin privileges
- Validates invitation belongs to the admin's organization
SSO Authentication Flow#
User Sign-In#
- User navigates to login page
- System calls
GET /auth/sso-providersto fetch available providers - Login page displays SSO provider buttons
- User clicks provider button (e.g., "Log in with acme-oidc")
- Call
authClient.signIn.sso()with provider ID - Redirects to identity provider's login page
- User authenticates with identity provider
- Identity provider redirects back to callback URL
- System validates invitation exists
- System creates user account and organization membership
- User is logged in and redirected to application
Account Linking#
When auto-linking is enabled for a provider, the system follows these rules enforced by the canLinkSsoAccount() function and database hooks:
- User signs in via SSO
- System checks if user with matching email already exists and validates account status through the
account.create.beforehook - If user exists and belongs to the SSO organization → links SSO account to existing user
- If user is new (no existing accounts) and has a valid invitation → creates new user account, links SSO account, and joins the SSO organization
- If user has an existing credential account → SSO sign-in is blocked by the database hook to prevent account conflicts, even with valid invitations or auto-linking enabled
- If user exists but belongs to a different organization → requires explicit invitation to join the SSO organization; auto-linking is blocked by the database hook
- User can now sign in with either SSO or username/password (if both are linked)
The canLinkSsoAccount() function determines whether linking is permitted by checking:
- Whether the user is already a member of the SSO provider's organization
- Whether the user has any existing accounts (credential or SSO)
- Whether the user has a valid pending invitation to the organization
Trusted Provider Configuration:
Account linking trust is managed through Better Auth's native account.accountLinking.trustedProviders configuration. The resolveTrustedProvidersForRequest() callback in app/server/modules/sso/sso.integration.ts dynamically determines which providers are trusted based on each organization's auto-linking settings:
- Extracts the provider ID from the SSO callback URL
- Queries the database for the provider's organization
- Returns a list of all providers in that organization with
autoLinkMatchingEmailsenabled - Better Auth uses this list to determine if automatic account linking should occur
This approach integrates with Better Auth's native account linking features while maintaining per-provider control through organization settings.
Organization Provisioning#
When SSO users sign in, they are automatically added to the organization associated with the SSO provider:
- Default Role:
member(configurable in Better Auth SSO plugin settings) - Organization Assignment: Users join the organization that registered the SSO provider
- Multiple Organizations: Users can be members of multiple organizations and switch between them
Two-Factor Authentication (2FA)#
Zerobyte supports TOTP (Time-based One-Time Password) two-factor authentication with encrypted backup codes for account recovery.
Setting Up 2FA#
The 2FA setup process follows a three-step workflow:
Step 1: Password Verification
Before enabling 2FA, users must re-authenticate:
- Enter current password for security verification
- Call
authClient.twoFactor.enable()with password - System returns TOTP URI and 5 backup codes
Step 2: Scan QR Code
Users configure their authenticator app:
- Display QR code encoding the TOTP URI
- Show manual entry code as alternative
- Display 5 backup codes for the user to save securely
- Backup codes are encrypted before storage
Step 3: Verify Setup
Confirm the authenticator is working:
- User enters 6-digit TOTP code from their app
- Call
authClient.twoFactor.verifyTotp()to verify - 2FA is now active on the account
Using 2FA During Login#
When 2FA is enabled, the login flow includes an additional verification step:
- User enters username and password successfully
- Redirected to 2FA verification page
- Enter 6-digit code from authenticator app
- Option to "Trust this device for 30 days" to skip 2FA on familiar devices
- Call
authClient.twoFactor.verifyTotp()to complete login
Backup Codes#
Users can regenerate backup codes at any time:
- Navigate to Settings → Security
- Click "Regenerate Backup Codes"
- Enter password for verification
- Call
authClient.twoFactor.generateBackupCodes() - System generates 5 new codes (invalidates previous codes)
- Save the new codes in a secure location
Disabling 2FA#
To disable 2FA:
- Navigate to Settings → Security
- Click "Disable Two-Factor Authentication"
- Enter password for confirmation
- Call
authClient.twoFactor.disable() - TOTP secret and backup codes are deleted
- User can log in with password only
Emergency 2FA Recovery#
If a user loses access to their authenticator app and backup codes, administrators can disable 2FA via CLI:
docker exec -it zerobyte bun run cli disable-2fa
The CLI command:
- Lists all users with 2FA enabled
- Prompts for user selection
- Removes TOTP secret and backup codes
- Sets
twoFactorEnabledto false
CLI Commands for Account Management#
Zerobyte provides command-line tools for emergency account recovery and administrative tasks. All commands are executed inside the Docker container.
Reset Password#
The reset password command allows password recovery when users are locked out:
docker exec -it zerobyte bun run cli reset-password
Workflow:
- Lists all users in the system
- Prompts for user selection
- Prompts for new password (minimum 8 characters)
- Confirms password entry
- Creates or updates credential account with password hash
- Updates legacy hash field if present
- Invalidates all active sessions for security
Behavior with SSO-only users: If the user only has SSO accounts (OAuth providers like Google, GitHub, etc.) and no credential account, the command automatically creates a credential account with the new password. This ensures users can always have a password-based login option.
Use case: When a user forgets their password and cannot access the reset functionality, or when an SSO-only user needs to add password-based authentication.
Change Username#
The change username command modifies a user's username:
docker exec -it zerobyte bun run cli change-username
Workflow:
- Lists all users
- Prompts for user selection
- Prompts for new username
- Validates username format (2-30 characters, lowercase letters, numbers, underscores, hyphens, and dots)
- Checks for uniqueness
- Updates username
- Invalidates all sessions
Use case: Update usernames to match new requirements or resolve validation errors.
Change Email#
The change email command allows administrators to update a user's email address:
docker exec -it zerobyte bun run cli change-email
Options:
-u, --username <username>- Username of the account (optional, prompts if not provided)-e, --email <email>- New email address (optional, prompts if not provided)
Workflow:
- Lists all users (if username not provided)
- Prompts for user selection
- Prompts for new email address
- Validates email format and checks for duplicates
- Verifies user has a credential account (prompts to reset password first if not)
- Displays linked SSO accounts that will be deleted
- Requires confirmation if SSO accounts exist
- Updates email address (normalized to lowercase)
- Deletes all linked SSO accounts (OAuth providers)
- Invalidates all existing sessions
Security implications: User will need to be re-invited with the new email to regain SSO access to any organizations.
Use case: Updating user contact information, handling email address changes for users who primarily use SSO authentication.
Disable Two-Factor Authentication#
The disable 2FA command removes 2FA from an account:
docker exec -it zerobyte bun run cli disable-2fa
Workflow:
- Lists all users with 2FA enabled
- Prompts for user selection
- Removes TOTP secret
- Deletes backup codes
- Sets
twoFactorEnabledto false
Use case: When a user loses access to their authenticator app and backup codes.
Assign Organization#
The assign organization command moves users between organizations:
docker exec -it zerobyte bun run cli assign-organization
Workflow:
- Lists all users
- Prompts for user selection
- Lists all organizations
- Prompts for target organization
- Updates or creates membership record
- Invalidates all sessions
Use case: Fixing "User does not belong to any organization" errors after upgrades, or reassigning users to different organizations.
Configuration and Settings#
Registration Control#
By default, Zerobyte operates in single-user mode where only the first user can register. Administrators can control whether additional users can self-register through the system settings.
Configuration Storage:
- Stored in the
appMetadataTablewith key"registrations_enabled" - Default value:
false(single-user mode)
Enabling/Disabling Registrations:
- Navigate to Admin → System
- Toggle "Allow new user registrations"
- Setting takes effect immediately
Enforcement:
The ensureOnlyOneUser middleware blocks signup attempts when registrations are disabled.
Security Features#
Password Requirements#
- Minimum length: 8 characters
- Hashing: Passwords are hashed using Better Auth's
hashPassword()function - Legacy support: System maintains legacy
passwordHashfield usingBun.password.hash()for backward compatibility
Session Management#
Sessions are stored in the database with the following characteristics:
- Persistent storage: All sessions tracked in the sessions table
- Expiration: Sessions have configurable expiration timestamps
- Organization context: Each session is tied to a specific organization via
activeOrganizationId - Revocation: Sessions can be invalidated individually or in bulk (e.g., on password change)
- Security metadata: IP address and user agent stored for audit purposes (see Proxy Configuration for deployments behind reverse proxies)
Date and Time Format Preferences#
Users can customize how dates and times are displayed throughout the application:
Configuring Preferences:
- Navigate to Settings → Account tab
- Locate the "Date and Time Format" section
- Select your preferred date format:
MM/DD/YYYY,DD/MM/YYYY, orYYYY/MM/DD - Select your preferred time format:
12h(12-hour with AM/PM) or24h(24-hour) - View a live preview showing how the selected format will display dates and times
- Changes take effect after saving and reloading the page
Default Settings:
- New users are assigned
MM/DD/YYYYfor date format and12hfor time format during registration - User preferences are stored in the
dateFormatandtimeFormatfields of the user record - These preferences are available throughout the application via the user context
Encryption#
The crypto utilities provide encryption for sensitive data:
Restic Passwords:
- Encrypted using AES-256-GCM
- Stored in organization metadata
- Required for backup encryption/decryption
- Downloadable only by organization admins with password verification
2FA Backup Codes:
- Encrypted before storage in database
- Decrypted only when displayed to user
- Invalidated when regenerated
Secret References:
- Supports
env://prefix for environment variables - Supports
file://prefix for reading from files - Enables external secret management systems
Environment Variables#
Critical environment variables for authentication:
| Variable | Purpose | Required |
|---|---|---|
APP_SECRET | Encryption key for secrets and session cookies | Yes |
BASE_URL | Base URL for the application (affects cookie security) | Yes |
ZEROBYTE_DATABASE_URL | SQLite database location | Yes |
TRUST_PROXY | Trust X-Forwarded-For headers from reverse proxies (default: false) | No |
Important: After v0.23.0 upgrades, ensure APP_SECRET, BASE_URL, and ZEROBYTE_DATABASE_URL are properly configured to avoid authentication issues.
Proxy Configuration#
When deploying Zerobyte behind a reverse proxy (such as Nginx, Apache, or a cloud load balancer), the TRUST_PROXY environment variable controls how the authentication system determines client IP addresses for session tracking and security auditing.
TRUST_PROXY Settings:
-
false(default): The system uses the direct connection IP address and does not trust externalX-Forwarded-Forheaders. This is appropriate for direct deployments without reverse proxies. -
true: The system trustsX-Forwarded-Forheaders from reverse proxies to identify the true client IP address. This is necessary when Zerobyte is deployed behind a reverse proxy to ensure accurate IP addresses are recorded in session metadata.
Configuration:
Set in your environment or Docker configuration:
TRUST_PROXY=true
Security Note: Only enable TRUST_PROXY when Zerobyte is behind a trusted reverse proxy. If enabled in a direct deployment, attackers could spoof IP addresses by sending forged X-Forwarded-For headers, compromising session security auditing.
Legacy User Migration#
For users upgrading from pre-v0.22.0 versions, Zerobyte includes automatic migration from the old authentication system to Better Auth.
Migration Process#
The migration happens automatically on the user's first login after upgrade:
- Detection: System detects legacy users by presence of
passwordHashfield - Password Verification: User's entered password is verified against the legacy hash
- New User Creation: Creates a new user record with Better Auth structure
- Organization Migration: Migrates existing organization membership
- Organization Creation: If no organization exists, creates a new personal organization
- Cleanup: Deletes the old legacy user record
- Session Establishment: Creates new session with Better Auth
What Gets Migrated#
- Username and email
- Password (re-hashed with Better Auth)
- Organization memberships and roles
- User role (admin or user)
What Doesn't Get Migrated#
- Active sessions (users must log in again)
- 2FA settings (must be reconfigured if previously enabled)
Post-Migration Steps#
After migration, users may need to:
- Log in with their existing credentials
- Re-enable 2FA if it was previously configured
- Download their recovery key if prompted
API Endpoints#
Authentication Endpoints#
Check System Status
Returns whether any users exist in the system:
{
"hasUsers": true
}
Used by the frontend to determine whether to show onboarding or login page.
SSO Endpoints#
Get Public SSO Providers
Returns publicly visible SSO providers for the login page:
{
"providers": [
{
"providerId": "acme-oidc",
"organizationSlug": "acme-workspace"
}
]
}
No authentication required. Called during login flow to display available SSO providers.
Map Auth Error to Code
Processes SSO authentication errors and redirects to the login page with a standardized error code.
Query Parameters:
error: Raw error message from the authentication provider
Behavior:
- Calls
mapAuthErrorToCode()fromapp/lib/sso-errors.tsto convert the error message to a standard error code - Redirects to
/login?error={errorCode}with the standardized error code
Error Code Mapping:
"account not linked","unable to link account","SSO account linking is not permitted for users outside this organization"→ACCOUNT_LINK_REQUIRED"EMAIL_NOT_VERIFIED"→EMAIL_NOT_VERIFIED"banned"→BANNED_USER- Various invite-related messages →
INVITE_REQUIRED - All other errors →
SSO_LOGIN_FAILED
Used as the errorCallbackURL for SSO sign-in to ensure consistent error handling across SSO providers.
Get SSO Settings (Organization Admin Only)
Returns complete SSO configuration for the active organization:
{
"providers": [
{
"providerId": "acme-oidc",
"type": "oidc",
"issuer": "https://idp.example.com",
"domain": "example.com",
"autoLinkMatchingEmails": true,
"organizationId": "org-123"
}
],
"invitations": [
{
"id": "inv-456",
"email": "user@example.com",
"role": "member",
"status": "pending",
"expiresAt": "2024-12-31T23:59:59Z"
}
]
}
Requires requireOrgAdmin middleware. Used by the SSO settings UI to display and manage providers and invitations.
Delete SSO Provider (Organization Admin Only)
DELETE /auth/sso-providers/:providerId
Deletes an SSO provider and all associated accounts:
- Requires
requireOrgAdminmiddleware - Validates provider belongs to the admin's organization
- Cascades deletion to all accounts using this provider
- Returns
404if provider not found
Response:
{
"success": true
}
Update SSO Provider Auto-Linking (Organization Admin Only)
PATCH /auth/sso-providers/:providerId/auto-linking
Updates whether SSO sign-in can auto-link existing accounts by email.
Request body:
{
"enabled": true
}
Response:
{
"success": true
}
Requires requireOrgAdmin middleware. Returns 404 if provider not found.
Delete SSO Invitation (Organization Admin Only)
DELETE /auth/sso-invitations/:invitationId
Deletes an SSO invitation:
- Requires
requireOrgAdminmiddleware - Validates invitation belongs to the admin's organization
- Returns
404if invitation not found
Response:
{
"success": true
}
User Management Endpoints#
Get Admin Users (Admin Only)
Returns list of users with their linked accounts:
{
"users": [
{
"id": "user-123",
"name": "Alice Smith",
"email": "alice@example.com",
"role": "admin",
"banned": false,
"accounts": [
{
"id": "acc-456",
"providerId": "credential"
},
{
"id": "acc-789",
"providerId": "acme-oidc"
}
]
}
],
"total": 10
}
Requires requireAdmin middleware. Used by the user management interface to display linked accounts and enable account management.
Get Organization Members (Organization Admin Only)
Returns list of members in the active organization:
{
"members": [
{
"id": "member-123",
"userId": "user-456",
"role": "admin",
"createdAt": "2024-01-15T10:30:00Z",
"user": {
"name": "Bob Smith",
"email": "bob@example.com"
}
}
]
}
Requires requireOrgAdmin middleware. Used by the organization member management interface.
Update Member Role (Organization Admin Only)
PATCH /auth/org-members/:memberId/role
Updates a member's role within the organization.
Request body:
{
"role": "admin"
}
Allowed roles: "member" or "admin"
Success response:
{
"success": true
}
Error responses:
403: Cannot change the role of the organization owner404: Member not found
Requires requireOrgAdmin middleware.
Remove Organization Member (Organization Admin Only)
DELETE /auth/org-members/:memberId
Removes a member from the organization.
Success response:
{
"success": true
}
Error responses:
403: Cannot remove the organization owner404: Member not found
Requires requireOrgAdmin middleware.
Delete User Account (Admin Only)
DELETE /auth/admin-users/:userId/accounts/:accountId
Deletes a linked account for a user:
- Requires
requireAdminmiddleware - Prevents deleting the last authentication method (returns
409 Conflict) - Returns
404if the account does not exist
Parameters:
userId: The user whose account should be deletedaccountId: The account ID to delete
Success response:
{
"success": true
}
Error responses:
404: Account not found409: Cannot delete the last account of a user
Get User Deletion Impact (Admin Only)
GET /auth/deletion-impact/:userId
Returns information about what will be affected if a user is deleted:
{
"organizations": [
{
"id": "org-123",
"name": "Alice's Workspace",
"volumeCount": 3,
"repositoryCount": 2,
"scheduleCount": 5
}
]
}
Requires requireAdmin middleware.
System Endpoints#
Get Registration Status
GET /system/registration-status
Returns whether new user registrations are enabled:
{
"enabled": false
}
Update Registration Status (Admin Only)
PUT /system/registration-status
Enable or disable new user registrations:
{
"enabled": true
}
Requires requireAdmin middleware.
Download Restic Recovery Key (Organization Admin)
Download the encrypted backup recovery key for the current organization:
- Requires
requireOrgAdminmiddleware - Requires password re-authentication in request body
- Returns decrypted Restic password
- Marks
hasDownloadedResticPasswordas true for the user
Common Issues and Troubleshooting#
Username Validation Errors#
Problem: Login fails with 422 Unprocessable Entity error due to username validation.
Cause: Username validation now requires usernames to be 2-30 characters, lowercase, and only contain letters, numbers, underscores, hyphens, and dots. Usernames with spaces or unsupported symbols are not allowed.
Solution: Use the CLI to change the username:
docker exec -it CONTAINER bun run cli change-username
Select the affected user and provide a new username using only lowercase letters, numbers, underscores, hyphens, or dots (2-30 characters).
Cookie Prefix Issues (HTTP/IP Address Access)#
Problem: In v0.23.0, login fails when accessing Zerobyte via IP address or HTTP.
Cause: The __Secure cookie prefix is rejected by browsers when not using HTTPS.
Solution:
- Upgrade to v0.23.1-beta.4 or later (fixed in this version)
- Access Zerobyte via HTTPS with a proper domain name when possible
- Ensure
APP_SECRETandBASE_URLenvironment variables are correctly configured
Organization Assignment Errors#
Problem: After upgrading to v0.23.0-v0.24.0, error message: "User does not belong to any organization"
Cause: During the major authentication system refactor, some users were assigned to new blank organizations instead of their existing ones.
Solution:
- Verify
APP_SECRETandBASE_URLenvironment variables are set - Run the organization assignment CLI tool:
docker exec -it zerobyte bun run cli assign-organization
- Select the affected user and assign them to the correct organization
Rate Limiter Issues#
Problem: In earlier versions, users received 429 (Too Many Requests) errors and login redirect loops.
Cause: The auth rate limiter keyed all users to an empty string when the x-forwarded-for header was absent.
Solution: Update to a recent version where this has been fixed. The rate limiter is now less aggressive and disabled in development mode.
General Troubleshooting Steps#
If you encounter authentication issues after upgrading:
- Check environment variables: Ensure
APP_SECRET,BASE_URL, andZEROBYTE_DATABASE_URLare correctly set - Clear browser data: Clear cookies and local storage for the Zerobyte domain
- Check logs: Review Docker logs for specific error messages:
docker logs zerobyte - Use CLI tools: All critical user operations have CLI fallbacks for emergency access
- Verify database migrations: Ensure database migrations completed successfully during upgrade
Architecture and Design#
Key Design Principles#
-
Multi-tenant by default: Every user belongs to at least one organization. All resources (volumes, repositories, backup schedules) are owned by organizations, not individual users. This provides clear isolation and enables future collaboration features.
-
First user is special: The first user automatically receives admin privileges and can control system-wide settings like user registration.
-
Security-first approach: Sensitive operations (2FA setup, recovery key download, user deletion) require password re-authentication, even for logged-in users.
-
CLI fallback for recovery: All critical operations have CLI command equivalents, ensuring administrators can recover from locked-out scenarios or authentication failures.
-
Cascade deletion: Deleting a user who is the sole owner of an organization triggers cascade deletion of the organization and all its resources (volumes, repositories, schedules). The system provides impact analysis before deletion. When organizations are deleted,
cleanupUserOrganizations()automatically handles session reassignment for users whose active organization is being removed:- Identifies users who are members of organizations being deleted
- Finds alternative organizations for each affected user
- Updates their sessions to use a fallback organization (or sets it to null if no alternatives exist)
- Wraps the entire process in a database transaction to ensure data consistency and prevent orphaned session references
-
Encrypted secrets at rest: Backup passwords are encrypted using AES-256-GCM with support for external secret management via
env://andfile://prefixes. -
Plugin-based authentication: Built on Better Auth's plugin architecture, making it easy to add new authentication methods or features in the future.
Session and State Management#
- Database-backed sessions: All sessions are persisted in the database, enabling session management across server restarts
- Organization context: Each session maintains an active organization ID, determining which resources the user can access
- Token-based authentication: Sessions use secure tokens for stateless authentication
- Automatic cleanup: Sessions are automatically invalidated on security-sensitive operations (password changes, role changes, organization reassignment)
Migration Strategy#
The system supports zero-downtime migration from the legacy authentication system. Users on older versions can upgrade to v0.22.0+ and will be automatically migrated on their first login, without requiring manual intervention or data loss.