Awesome-omni-skill better-auth-best-practices

Skill for integrating Better Auth - the comprehensive TypeScript authentication framework.

install
source · Clone the upstream repo
git clone https://github.com/diegosouzapw/awesome-omni-skill
Claude Code · Install into ~/.claude/skills/
T=$(mktemp -d) && git clone --depth=1 https://github.com/diegosouzapw/awesome-omni-skill "$T" && mkdir -p ~/.claude/skills && cp -r "$T/skills/development/better-auth-best-practices-epicenterhq" ~/.claude/skills/diegosouzapw-awesome-omni-skill-better-auth-best-practices-8da2b5 && rm -rf "$T"
manifest: skills/development/better-auth-best-practices-epicenterhq/SKILL.md
source content

Better Auth Integration Guide

Always consult better-auth.com/docs for code examples and latest API.

Better Auth is a TypeScript-first, framework-agnostic auth framework supporting email/password, OAuth, magic links, passkeys, and more via plugins.


Quick Reference

Environment Variables

  • BETTER_AUTH_SECRET
    - Encryption secret (min 32 chars). Generate:
    openssl rand -base64 32
  • BETTER_AUTH_URL
    - Base URL (e.g.,
    https://example.com
    )

Only define

baseURL
/
secret
in config if env vars are NOT set.

File Location

CLI looks for

auth.ts
in:
./
,
./lib
,
./utils
, or under
./src
. Use
--config
for custom path.

CLI Commands

  • npx @better-auth/cli@latest migrate
    - Apply schema (built-in adapter)
  • npx @better-auth/cli@latest generate
    - Generate schema for Prisma/Drizzle
  • npx @better-auth/cli mcp --cursor
    - Add MCP to AI tools

Re-run after adding/changing plugins.


Core Config Options

OptionNotes
appName
Optional display name
baseURL
Only if
BETTER_AUTH_URL
not set
basePath
Default
/api/auth
. Set
/
for root.
secret
Only if
BETTER_AUTH_SECRET
not set
database
Required for most features. See adapters docs.
secondaryStorage
Redis/KV for sessions & rate limits
emailAndPassword
{ enabled: true }
to activate
socialProviders
{ google: { clientId, clientSecret }, ... }
plugins
Array of plugins
trustedOrigins
CSRF whitelist

Database

Direct connections: Pass

pg.Pool
,
mysql2
pool,
better-sqlite3
, or
bun:sqlite
instance.

ORM adapters: Import from

better-auth/adapters/drizzle
,
better-auth/adapters/prisma
,
better-auth/adapters/mongodb
.

Critical: Better Auth uses adapter model names, NOT underlying table names. If Prisma model is

User
mapping to table
users
, use
modelName: "user"
(Prisma reference), not
"users"
.


Session Management

Storage priority:

  1. If
    secondaryStorage
    defined → sessions go there (not DB)
  2. Set
    session.storeSessionInDatabase: true
    to also persist to DB
  3. No database +
    cookieCache
    → fully stateless mode

Cookie cache strategies:

  • compact
    (default) - Base64url + HMAC. Smallest.
  • jwt
    - Standard JWT. Readable but signed.
  • jwe
    - Encrypted. Maximum security.

Key options:

session.expiresIn
(default 7 days),
session.updateAge
(refresh interval),
session.cookieCache.maxAge
,
session.cookieCache.version
(change to invalidate all sessions).


User & Account Config

User:

user.modelName
,
user.fields
(column mapping),
user.additionalFields
,
user.changeEmail.enabled
(disabled by default),
user.deleteUser.enabled
(disabled by default).

Account:

account.modelName
,
account.accountLinking.enabled
,
account.storeAccountCookie
(for stateless OAuth).

Required for registration:

email
and
name
fields.


Email Flows

  • emailVerification.sendVerificationEmail
    - Must be defined for verification to work
  • emailVerification.sendOnSignUp
    /
    sendOnSignIn
    - Auto-send triggers
  • emailAndPassword.sendResetPassword
    - Password reset email handler

Security

In

advanced
:

  • useSecureCookies
    - Force HTTPS cookies
  • disableCSRFCheck
    - ⚠️ Security risk
  • disableOriginCheck
    - ⚠️ Security risk
  • crossSubDomainCookies.enabled
    - Share cookies across subdomains
  • ipAddress.ipAddressHeaders
    - Custom IP headers for proxies
  • database.generateId
    - Custom ID generation or
    "serial"
    /
    "uuid"
    /
    false

Rate limiting:

rateLimit.enabled
,
rateLimit.window
,
rateLimit.max
,
rateLimit.storage
("memory" | "database" | "secondary-storage").


Hooks

Endpoint hooks:

hooks.before
/
hooks.after
- Array of
{ matcher, handler }
. Use
createAuthMiddleware
. Access
ctx.path
,
ctx.context.returned
(after),
ctx.context.session
.

Database hooks:

databaseHooks.user.create.before/after
, same for
session
,
account
. Useful for adding default values or post-creation actions.

Hook context (

ctx.context
):
session
,
secret
,
authCookies
,
password.hash()
/
verify()
,
adapter
,
internalAdapter
,
generateId()
,
tables
,
baseURL
.


Plugins

Import from dedicated paths for tree-shaking:

import { twoFactor } from "better-auth/plugins/two-factor"

NOT

from "better-auth/plugins"
.

Popular plugins:

twoFactor
,
organization
,
passkey
,
magicLink
,
emailOtp
,
username
,
phoneNumber
,
admin
,
apiKey
,
bearer
,
jwt
,
multiSession
,
sso
,
oauthProvider
,
oidcProvider
,
openAPI
,
genericOAuth
.

Client plugins go in

createAuthClient({ plugins: [...] })
.


Native App / Desktop Auth (Tauri, Electron, Expo)

Cookies are unreliable in non-browser contexts (Tauri webview rejects

Secure
cookies from
tauri://
, split cookie jars between webview and Rust HTTP, debug/release inconsistencies).

Use the

bearer()
plugin. It makes Better Auth work for native apps without changing server-side session logic:

  • bearer()
    adds
    set-auth-token
    response header to all auth responses
  • Sign-in/sign-up endpoints already return
    { token: session.token }
    in the response body
  • The client extracts the token from the body or header, stores it locally
  • On subsequent requests:
    Authorization: Bearer <token>
    → bearer() converts to cookie internally → Better Auth validates normally

Client config for native apps:

const authClient = createAuthClient({
  baseURL: 'https://hub.example.com',
  fetchOptions: {
    auth: {
      type: 'Bearer',
      token: () => localStorage.getItem('session-token') || '',
    },
  },
});

Token extraction on sign-in:

const { data } = await authClient.signIn.email({
  email: 'user@example.com',
  password: 'password',
}, {
  onSuccess: (ctx) => {
    const token = ctx.response.headers.get('set-auth-token');
    localStorage.setItem('session-token', token);
  },
});

Key point: bearer() handles opaque HMAC session tokens only. It does NOT validate JWTs. The

jwt()
plugin is separate and for issuing tokens to external services, not for native app auth.


Client

Import from:

better-auth/client
(vanilla),
better-auth/react
,
better-auth/vue
,
better-auth/svelte
,
better-auth/solid
.

Key methods:

signUp.email()
,
signIn.email()
,
signIn.social()
,
signOut()
,
useSession()
,
getSession()
,
revokeSession()
,
revokeSessions()
.


Type Safety

Infer types:

typeof auth.$Infer.Session
,
typeof auth.$Infer.Session.user
.

For separate client/server projects:

createAuthClient<typeof auth>()
.


Common Gotchas

  1. Model vs table name - Config uses ORM model name, not DB table name
  2. Plugin schema - Re-run CLI after adding plugins
  3. Secondary storage - Sessions go there by default, not DB
  4. Cookie cache - Custom session fields NOT cached, always re-fetched
  5. Stateless mode - No DB = session in cookie only, logout on cache expiry
  6. Change email flow - Sends to current email first, then new email

Resources