How Thunderbolt's Backend Authentication System Works with Better Auth
Thunderbolt implements a password-less backend authentication system using Better Auth with a custom factory that configures OTP-based sign-in, PostgreSQL persistence via Drizzle, and hooks for waitlist validation and cleanup.
The Thunderbolt email client (thunderbird/thunderbolt) uses a sophisticated backend authentication system that eliminates passwords entirely. This implementation leverages the Better Auth library integrated with PostgreSQL through Drizzle ORM to handle OTP-based sign-in flows. The architecture combines rate limiting, device binding, and optional OIDC support to create a secure, scalable authentication layer.
Better Auth Factory Configuration
The core of Thunderbolt's backend authentication system resides in the createAuth factory function located in backend/src/auth/auth.ts. This function initializes the Better Auth instance with PostgreSQL support and custom plugins.
Database Adapter and Schema
The factory connects Better Auth to the existing Drizzle ORM setup using the drizzleAdapter:
betterAuth({
database: drizzleAdapter(database, {
provider: 'pg',
schema
})
})
This configuration stores verification rows, sessions, and user data in PostgreSQL while respecting the existing schema definitions in backend/src/dal/otp-challenge.ts and related data access layers.
Trusted Origins and Security Headers
The factory parses process.env.TRUSTED_ORIGINS to configure CORS and validate magic-link URLs. It also configures IP address headers for proxy environments:
advanced: {
ipAddress: {
ipAddressHeaders: getTrustedIpHeaders(settings.trustedProxy)
}
}
This ensures accurate client IP detection when running behind Cloudflare or Akamai, which feeds into rate-limiting decisions.
Rate Limiting Configuration
The backend authentication system implements two layers of protection against brute-force attacks:
- Global rate limit:
rateLimit: { enabled: true, window: 60, max: 10 }restricts the OTP endpoint to 10 requests per minute per IP address. - Per-challenge limits: The
emailOTPplugin enforcesallowedAttempts: 3, returningTOO_MANY_ATTEMPTSafter three failed OTP entries.
OTP Authentication Flow
Thunderbolt's password-less flow relies on email-based one-time passwords coordinated through Better Auth plugins and custom middleware hooks.
Challenge Token Generation
When a user initiates sign-in, the system creates a cryptographically secure challenge token stored in backend/src/dal/otp-challenge.ts. The getOrCreateOtpChallenge function implements a first-writer-wins strategy to prevent race conditions.
The challenge token binds the OTP request to a specific sign-in attempt, preventing replay attacks across different devices or sessions.
Before Hook Validation
The createAuth factory registers a before hook (via createAuthMiddleware) that executes before Better Auth processes the OTP verification:
- Validates the request path matches the OTP endpoint
- Extracts the
x-challenge-tokenheader (defined inbackend/src/auth/otp-constants.ts) - Calls
validateOtpChallengeto confirm the token is non-expired and matches the email - For new users, checks the waitlist status via
isAutoApprovedDomainor ensures manual approval
If validation fails, the hook throws UNAUTHORIZED before the OTP reaches Better Auth's verification logic.
After Hook Cleanup
Upon successful authentication, the after hook performs critical cleanup:
deleteOtpChallengesForEmail: Removes all challenge tokens for the authenticated emaildeletePersistedSignInOtp: Cleans up the verification row stored by Better Auth'semailOTPpluginmarkUserNotNew: Updates theisNewflag inuser.additionalFieldsto disable first-login flows
This ensures that OTPs remain single-use and session state stays consistent.
Waitlist and Access Control
The backend authentication system integrates with Thunderbolt's waitlist feature to gate access. Located primarily in backend/src/waitlist/utils.ts, this system:
- Automatically approves users from specific domains via
isAutoApprovedDomain - Adds new users to the waitlist with
createWaitlistEntryif not auto-approved - Sends "waitlist-joined" or "not-ready" emails instead of OTPs when access is pending
The before hook enforces these restrictions, ensuring only approved users can complete the OTP flow and create sessions.
Optional OIDC Integration
When settings.authMode === 'oidc', the factory invokes buildOidcPlugins() to add OpenID Connect support. This returns a genericOAuth plugin configured with:
- Issuer URL, client ID, and client secret from environment variables
- Scopes for profile and email access
- Redirect URL pointing to
${settings.betterAuthUrl}/v1/api/auth/oauth2/callback/oidc
This allows the backend authentication system to support hybrid authentication, accommodating both password-less OTP for individual users and OIDC for enterprise environments.
Session and Device Binding
The createAuth factory configures additional session fields to enhance security:
session: {
additionalFields: {
deviceId: { type: 'string', required: false }
}
}
The client supplies a deviceId during authentication, which binds the session to a specific device. Combined with the challenge token mechanism, this prevents cross-device session theft and replay attacks.
Code Examples
Initialize Better Auth in the Server
import { createAuth } from '@/auth/auth';
import { db } from '@/db/client';
// Called when the backend starts
export const auth = createAuth(db);
Trigger OTP Send (Client Side)
await fetch(`${API_URL}/sign-in/email-otp`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: userEmail }),
});
Better Auth invokes the emailOTP.sendVerificationOTP plugin, which calls sendSignInEmail with the OTP and magic link generated via buildVerifyUrl from backend/src/auth/utils.ts.
Complete Sign-in with OTP
const response = await fetch(`${API_URL}/sign-in/email-otp`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-challenge-token': challengeToken, // from the previous step
},
body: JSON.stringify({ email: userEmail, otp: enteredOtp }),
});
const { session, user } = await response.json();
localStorage.setItem('authToken', session.accessToken);
The before hook validates the challenge token using validateOtpChallenge from backend/src/dal/otp-challenge.ts, while the after hook cleans up used tokens and marks the user as not new.
Using Bearer Token for API Calls
const token = localStorage.getItem('authToken');
await fetch(PROTECTED_ENDPOINT, {
headers: { Authorization: `Bearer ${token}` },
});
The bearer plugin configured with requireSignature: true enforces signed JWTs for mobile client requests.
Key Implementation Files
| File | Purpose |
|---|---|
backend/src/auth/auth.ts |
Core createAuth factory, Better Auth configuration, plugins, and hooks |
backend/src/dal/otp-challenge.ts |
Challenge token storage with getOrCreateOtpChallenge, validateOtpChallenge, and cleanup functions |
backend/src/auth/otp-constants.ts |
Constants for OTP expiry durations and the x-challenge-token header name |
backend/src/auth/utils.ts |
Utility functions including buildVerifyUrl for magic links and getValidatedOrigin for CORS validation |
backend/src/waitlist/utils.ts |
Waitlist logic including isAutoApprovedDomain and email notification helpers |
Summary
- Thunderbolt's backend authentication system uses a custom
createAuthfactory inbackend/src/auth/auth.tsto configure Better Auth with PostgreSQL via Drizzle ORM. - The system implements password-less sign-in through email OTPs with challenge tokens stored in
backend/src/dal/otp-challenge.tsto prevent replay attacks. - Custom before and after hooks enforce waitlist validation, rate limiting (10 requests/minute globally, 3 attempts per OTP), and automatic cleanup of used tokens.
- Optional OIDC support via
buildOidcPlugins()allows hybrid authentication alongside the OTP flow. - Sessions include device binding through the
deviceIdfield insession.additionalFieldsto enhance security across multiple clients.
Frequently Asked Questions
How does Thunderbolt prevent brute-force attacks on the OTP endpoint?
The backend authentication system implements two layers of protection. First, a global rate limit configured in backend/src/auth/auth.ts restricts the OTP endpoint to 10 requests per minute per IP address. Second, the emailOTP plugin enforces a per-challenge limit of 3 attempts, returning TOO_MANY_ATTEMPTS after exhausted tries. IP addresses are correctly extracted using getTrustedIpHeaders when running behind proxies like Cloudflare.
What is the purpose of the challenge token in Thunderbolt's authentication flow?
The challenge token serves as a cryptographic binding between the OTP request and the specific sign-in attempt. Stored in backend/src/dal/otp-challenge.ts via getOrCreateOtpChallenge, this token must be passed in the x-challenge-token header (defined in backend/src/auth/otp-constants.ts) during OTP verification. The before hook validates this token using validateOtpChallenge to prevent replay attacks across different devices or sessions.
Can Thunderbolt authenticate users through corporate identity providers?
Yes, when settings.authMode is set to 'oidc', the createAuth factory invokes buildOidcPlugins() to add OpenID Connect support. This returns a genericOAuth plugin configured with issuer URLs, client credentials, and scopes, redirecting to ${settings.betterAuthUrl}/v1/api/auth/oauth2/callback/oidc. This allows the backend authentication system to support both password-less OTP and enterprise OIDC simultaneously.
How does the waitlist integration gate access to Thunderbolt?
The before hook in backend/src/auth/auth.ts enforces waitlist validation before allowing OTP generation. For new users, the system checks isAutoApprovedDomain in backend/src/waitlist/utils.ts to bypass manual approval for specific domains. Unapproved users are added via createWaitlistEntry and receive waitlist notification emails instead of OTPs, effectively gating access until manual or automatic approval is granted.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →