Authentication System
Better-Auth configuration, email OTP, OAuth providers, session management, and rate limiting.
Authentication System
VeriWorkly uses Better-Auth as its authentication framework, with passwordless email OTP as the primary method and three OAuth providers alongside it.
Authentication methods
- Email OTP — a one-time passcode is emailed to the user; verifying it establishes a session.
Configured through Better-Auth's
emailOTPplugin. - Google OAuth
- GitHub OAuth
- LinkedIn OAuth
LinkedIn OAuth is for signing in only
LinkedIn OAuth authenticates a user. It is not used to import LinkedIn profile data — that flow is a paste-and-parse path. See Importing Your Profile.
Each OAuth provider is registered only if its client ID and secret are both configured, so an unconfigured provider simply does not appear rather than failing at runtime.
Account linking
Account linking is enabled with google, github, and linkedin marked as trusted providers, so a
user who signs up with email OTP and later signs in with Google lands on the same account rather than
creating a duplicate.
Architecture
- Frontends (
apps/studio,apps/portfolio,apps/site) use the Better-Auth client SDK for session lifecycle and the OTP verification UI. - Backend (
apps/server) is the authentication authority. Better-Auth is mounted at the/api/v1/authbase path and handles OTP generation, OAuth callbacks, session issuance, and email dispatch.
Session storage
Sessions are stored in both PostgreSQL and Redis:
storeSessionInDatabase: truewrites each session to theSessiontable.- A
secondaryStorageadapter backed by Redis caches session lookups, so the hot path does not hit PostgreSQL on every request.
| Setting | Environment variable | Default |
|---|---|---|
| Session lifetime | AUTH_SESSION_TTL_SECONDS | 2592000 (30 days) |
| Session refresh interval | AUTH_SESSION_RESET_TTL_ON_USE | 86400 (24 hours) |
| Cookie cache enabled | AUTH_SESSION_CACHE_ENABLED | true in production, false otherwise |
| Cookie cache max age | AUTH_SESSION_CACHE_MAX_AGE_SECONDS | 900 (15 minutes) |
Cookies
- Cookie prefix:
veriworkly-auth. useSecureCookiesis enabled whenNODE_ENV=production, producing__Secure--prefixed cookies.- Cross-subdomain sessions are enabled when
AUTH_COOKIE_DOMAINis set. Scoping the cookie to.veriworkly.comis what lets a login on Studio carry over to the portfolio builder and the published-portfolio host.
Proxy trust
trustedProxyHeaders is enabled, and the headers consulted for the client IP are configurable via
AUTH_IP_ADDRESS_HEADERS (default:
x-client-ip,x-forwarded-for,x-real-ip,cf-connecting-ip). This keeps IP-derived rate limiting and
login alerts accurate behind Nginx or Cloudflare.
Configuration reference
export const auth = betterAuth({
database: prismaAdapter(prisma, { provider: "postgresql" }),
secondaryStorage: {/* Redis get / set / delete */},
basePath: "/api/v1/auth",
trustedOrigins: config.allowedOrigins,
advanced: {
trustedProxyHeaders: true,
cookiePrefix: "veriworkly-auth",
useSecureCookies: config.nodeEnv === "production",
crossSubDomainCookies: {
enabled: !!config.auth.cookieDomain,
domain: config.auth.cookieDomain,
},
},
plugins: [
emailOTP({
expiresIn: config.auth.otpTtlSeconds,
allowedAttempts: config.auth.otpAllowedAttempts,
sendVerificationOTP: async ({ email, otp, type }) => {
await sendAuthOtpEmail({ email, otp, type });
},
}),
],
});Security controls
OTP lifetime and attempts
| Control | Environment variable | Default |
|---|---|---|
| OTP validity window | AUTH_OTP_TTL_SECONDS | 300 (5 minutes) |
| Allowed failed attempts | AUTH_OTP_ALLOWED_ATTEMPTS | 3 |
Rate limiting
Better-Auth's own limiter runs on Redis, with a tighter custom rule on the send-OTP endpoint:
| Endpoint | Effective limit |
|---|---|
/email-otp/send-verification-otp | 3 requests / 60 seconds (custom rule) |
/sign-in/email-otp | Falls back to the default below — see the caveat |
| All other auth routes | AUTH_RATE_LIMIT_MAX_REQUESTS (default 20) per AUTH_RATE_LIMIT_WINDOW_MS (default 60s) |
The application's own IP-based rate limiter runs in front of this as an additional layer, also at 20
requests / 60 seconds for /api/v1/auth/*.
The OTP-verification custom rule is dead configuration
auth/index.ts defines a customRules entry for /email-otp/verify-otp at 5 requests / 60
seconds, but the Better-Auth version in use does not expose that path — the email-OTP plugin
provides /email-otp/send-verification-otp, /email-otp/verify-email,
/email-otp/check-verification-otp, and /sign-in/email-otp. The rule therefore never matches,
and OTP submission is limited only by the 20/60s default. Retargeting it at /sign-in/email-otp
would restore the intended tighter limit.
Session cache invalidation
A before hook clears cached session data on /sign-out, /revoke-session, /revoke-sessions,
/change-email, /change-password, and /delete-user, so a revoked session cannot keep working for
the remainder of its Redis cache TTL. Updating or deleting a user invalidates every one of their
cached sessions by token.
Automatic emails
Database hooks send transactional email on account lifecycle events:
- Welcome email — on user creation.
- New-device login alert — on session creation, including the provider, IP, and user agent. Suppressed within the first 15 seconds after signup to avoid duplicating the welcome email.
- Account deletion confirmation — before a user record is deleted.
Admin provisioning
On server boot, if no admin account exists for the configured ADMIN_EMAIL, one is provisioned
automatically. ADMIN_EMAIL is compared case-insensitively and is a server-only value — it is
never sent to the browser.
Self-service account deletion is not exposed
The backend has the deletion machinery and the confirmation email template, but Better-Auth's
deleteUser endpoint is not enabled and no Studio UI calls it. Account deletion is currently
handled by contacting [email protected]. See Account
Management.
For the underlying tables, see Database Schema. For programmatic (non-session) access, see API Keys.