Authentication is essential for most applications, but implementing it securely requires careful attention to session management, password hashing, and security best practices. BetterAuth is a modern, open-source TypeScript authentication framework that handles these complexities while remaining lightweight and flexible.
In this tutorial, we’ll build a complete authentication backend using BetterAuth and Encore.ts. You’ll learn how to set up user registration, login, session management, and protected API endpoints with full type safety from backend to frontend, while Encore handles infrastructure provisioning and provides built-in observability.
What is BetterAuth?
[BetterAuth](https://better-auth.com/…
Authentication is essential for most applications, but implementing it securely requires careful attention to session management, password hashing, and security best practices. BetterAuth is a modern, open-source TypeScript authentication framework that handles these complexities while remaining lightweight and flexible.
In this tutorial, we’ll build a complete authentication backend using BetterAuth and Encore.ts. You’ll learn how to set up user registration, login, session management, and protected API endpoints with full type safety from backend to frontend, while Encore handles infrastructure provisioning and provides built-in observability.
What is BetterAuth?
BetterAuth is a comprehensive TypeScript authentication framework designed for modern web applications. It provides:
- Email & Password Authentication with secure password hashing and session management
- Social Sign-On with OAuth providers (Google, GitHub, and more)
- Two-Factor Authentication (2FA) for enhanced security
- Plugin Ecosystem for extending functionality (organizations, multi-tenant, etc.)
- Framework-Agnostic works with any TypeScript backend
- Type-Safe built with TypeScript from the ground up
BetterAuth provides a complete authentication solution out of the box, from password hashing to OAuth integration.
What we’re building
We’ll create a backend authentication system with:
- User registration with email and password
- Login/logout with JWT session management
- Protected API endpoints that require authentication
- User profile endpoint to retrieve authenticated user data
- PostgreSQL database for user storage
- Type-safe auth data accessible throughout your application
The backend will handle all authentication logic, with BetterAuth managing sessions, password hashing, and security best practices. Encore’s type-safe architecture ensures that you can protect any API endpoint with a simple auth: true flag and access authenticated user information throughout your application with full TypeScript support.
Getting started
First, install Encore if you haven’t already. It automatically provisions infrastructure like databases and pub/sub, while providing built-in local development tools including a service dashboard, distributed tracing, and API documentation:
# macOS
brew install encoredev/tap/encore
# Linux
curl -L https://encore.dev/install.sh | bash
# Windows
iwr https://encore.dev/install.ps1 | iex
Create a new Encore application from the TypeScript hello-world template. This will prompt you to create a free Encore account if you don’t have one (required for secret management):
encore app create auth-app --example=ts/hello-world
cd auth-app
Backend implementation
Installing dependencies
Install BetterAuth, Drizzle ORM, and required dependencies:
npm install better-auth drizzle-orm pg
npm install -D drizzle-kit
We’re using Drizzle ORM for type-safe database queries, which integrates seamlessly with Encore’s database infrastructure. Drizzle requires the pg (node-postgres) package as its PostgreSQL driver, which BetterAuth also uses for database connections.
Setting up the database
User accounts and sessions need to be persisted in a database. With Encore, you can create a PostgreSQL database by simply defining it in code. The framework automatically provisions the infrastructure locally using Docker.
First, define the Drizzle schema for BetterAuth’s tables:
// auth/schema.ts
import { pgTable, text, timestamp, boolean } from "drizzle-orm/pg-core";
export const user = pgTable("user", {
id: text("id").primaryKey(),
name: text("name").notNull(),
email: text("email").notNull().unique(),
emailVerified: boolean("emailVerified").notNull().default(false),
image: text("image"),
createdAt: timestamp("createdAt").notNull().defaultNow(),
updatedAt: timestamp("updatedAt").notNull().defaultNow(),
});
export const session = pgTable("session", {
id: text("id").primaryKey(),
expiresAt: timestamp("expiresAt").notNull(),
token: text("token").notNull().unique(),
createdAt: timestamp("createdAt").notNull().defaultNow(),
updatedAt: timestamp("updatedAt").notNull().defaultNow(),
ipAddress: text("ipAddress"),
userAgent: text("userAgent"),
userId: text("userId")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
});
export const account = pgTable("account", {
id: text("id").primaryKey(),
accountId: text("accountId").notNull(),
providerId: text("providerId").notNull(),
userId: text("userId")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
accessToken: text("accessToken"),
refreshToken: text("refreshToken"),
idToken: text("idToken"),
accessTokenExpiresAt: timestamp("accessTokenExpiresAt"),
refreshTokenExpiresAt: timestamp("refreshTokenExpiresAt"),
scope: text("scope"),
password: text("password"),
createdAt: timestamp("createdAt").notNull().defaultNow(),
updatedAt: timestamp("updatedAt").notNull().defaultNow(),
});
export const verification = pgTable("verification", {
id: text("id").primaryKey(),
identifier: text("identifier").notNull(),
value: text("value").notNull(),
expiresAt: timestamp("expiresAt").notNull(),
createdAt: timestamp("createdAt"),
updatedAt: timestamp("updatedAt"),
});
Now create the database instance with Encore:
// auth/db.ts
import { SQLDatabase } from "encore.dev/storage/sqldb";
import { drizzle } from "drizzle-orm/node-postgres";
import { Pool } from "pg";
import * as schema from "./schema";
export const DB = new SQLDatabase("auth", {
migrations: "./migrations",
});
// Create Drizzle instance
const pool = new Pool({
connectionString: DB.connectionString,
});
export const db = drizzle(pool, { schema });
Note: We’re using Encore’s SQL migration files instead of Drizzle Kit’s migration tools. Encore handles applying migrations across all environments and integrates with the deployment pipeline, while Drizzle gives us type-safe queries.
The authentication system requires database tables for users, sessions, accounts, and verification tokens. Encore uses SQL migration files to define your database schema, which are automatically applied when your application starts. Create the initial migration file with the required BetterAuth tables:
-- auth/migrations/1_create_auth_tables.up.sql
CREATE TABLE IF NOT EXISTS "user" (
"id" TEXT PRIMARY KEY NOT NULL,
"name" TEXT NOT NULL,
"email" TEXT NOT NULL UNIQUE,
"emailVerified" BOOLEAN NOT NULL DEFAULT false,
"image" TEXT,
"createdAt" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS "session" (
"id" TEXT PRIMARY KEY NOT NULL,
"expiresAt" TIMESTAMP NOT NULL,
"token" TEXT NOT NULL UNIQUE,
"createdAt" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
"ipAddress" TEXT,
"userAgent" TEXT,
"userId" TEXT NOT NULL REFERENCES "user"("id") ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS "account" (
"id" TEXT PRIMARY KEY NOT NULL,
"accountId" TEXT NOT NULL,
"providerId" TEXT NOT NULL,
"userId" TEXT NOT NULL REFERENCES "user"("id") ON DELETE CASCADE,
"accessToken" TEXT,
"refreshToken" TEXT,
"idToken" TEXT,
"accessTokenExpiresAt" TIMESTAMP,
"refreshTokenExpiresAt" TIMESTAMP,
"scope" TEXT,
"password" TEXT,
"createdAt" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS "verification" (
"id" TEXT PRIMARY KEY NOT NULL,
"identifier" TEXT NOT NULL,
"value" TEXT NOT NULL,
"expiresAt" TIMESTAMP NOT NULL,
"createdAt" TIMESTAMP,
"updatedAt" TIMESTAMP
);
Creating the auth service
Every Encore service starts with a service definition file (encore.service.ts). Services let you divide your application into logical components. At deploy time, you can decide whether to colocate them in a single process or deploy them as separate microservices, without changing a single line of code:
// auth/encore.service.ts
import { Service } from "encore.dev/service";
export default new Service("auth");
Configuring BetterAuth
Now we’ll configure BetterAuth to use our PostgreSQL database. We’ll create a connection pool using the pg package and pass it to BetterAuth. Encore’s SQLDatabase provides a connectionString that we can use. We’ll also use Encore’s secrets management to securely store the authentication secret key:
// auth/better-auth.ts
import { betterAuth } from "better-auth";
import { Pool } from "pg";
import { DB } from "./db";
import { secret } from "encore.dev/config";
// Secrets let you store sensitive values like API keys securely
// Learn more: https://encore.dev/docs/ts/primitives/secrets
const authSecret = secret("BetterAuthSecret");
// Create a PostgreSQL pool for BetterAuth
const pool = new Pool({
connectionString: DB.connectionString,
});
// Create BetterAuth instance with database connection
export const auth = betterAuth({
database: pool,
secret: authSecret(),
emailAndPassword: {
enabled: true,
requireEmailVerification: false, // Set to true in production
},
session: {
expiresIn: 60 * 60 * 24 * 7, // 7 days
updateAge: 60 * 60 * 24, // Update session every 24 hours
},
});
Set your auth secret using Encore’s CLI (learn more about secrets management). You’ll be prompted to enter the secret value securely:
# Set the secret for local development
encore secret set --dev BetterAuthSecret
# For production environments
encore secret set --prod BetterAuthSecret
Tip: Generate a strong random secret using openssl rand -base64 32 or a password manager.
Implementing authentication endpoints
With the configuration in place, let’s build the API endpoints that handle user registration, login, and logout. In Encore, endpoints are defined using the api function with TypeScript interfaces for request and response validation, providing automatic request parsing, validation, and API documentation:
// auth/auth.ts
import { api } from "encore.dev/api";
import { auth } from "./better-auth";
import log from "encore.dev/log";
// Register a new user
interface SignUpRequest {
email: string;
password: string;
name: string;
}
interface AuthResponse {
user: {
id: string;
email: string;
name: string;
};
session: {
token: string;
expiresAt: Date;
};
}
export const signUp = api(
{ expose: true, method: "POST", path: "/auth/signup" },
async (req: SignUpRequest): Promise<AuthResponse> => {
log.info("User signup attempt", { email: req.email });
// Use BetterAuth to create user
const result = await auth.api.signUpEmail({
body: {
email: req.email,
password: req.password,
name: req.name,
},
});
if (!result.user || !result.token) {
throw new Error("Failed to create user");
}
return {
user: {
id: result.user.id,
email: result.user.email,
name: result.user.name,
},
session: {
token: result.token,
expiresAt: new Date(Date.now() + 60 * 60 * 24 * 7 * 1000), // 7 days from now
},
};
}
);
// Login existing user
interface SignInRequest {
email: string;
password: string;
}
export const signIn = api(
{ expose: true, method: "POST", path: "/auth/signin" },
async (req: SignInRequest): Promise<AuthResponse> => {
log.info("User signin attempt", { email: req.email });
const result = await auth.api.signInEmail({
body: {
email: req.email,
password: req.password,
},
});
if (!result.user || !result.token) {
throw new Error("Invalid credentials");
}
return {
user: {
id: result.user.id,
email: result.user.email,
name: result.user.name,
},
session: {
token: result.token,
expiresAt: new Date(Date.now() + 60 * 60 * 24 * 7 * 1000), // 7 days from now
},
};
}
);
// Logout user
interface SignOutRequest {
token: string;
}
export const signOut = api(
{ expose: true, method: "POST", path: "/auth/signout" },
async (req: SignOutRequest): Promise<{ success: boolean }> => {
await auth.api.signOut({
body: { token: req.token },
});
return { success: true };
}
);
Creating the auth handler
To protect endpoints and enable authentication across your application, we need to create an auth handler. The auth handler is a special function that Encore calls automatically for any incoming request containing authentication parameters (like an Authorization header). It verifies session tokens and makes authenticated user data available to protected endpoints.
Note on session validation: BetterAuth’s built-in session management (auth.api.getSession()) is designed for cookie-based authentication in web browsers. For REST API bearer tokens, we validate sessions by querying the database directly. This approach is standard for API authentication and gives us full control over the validation logic while still leveraging BetterAuth for the security-critical parts (password hashing, user creation, and session storage).
// auth/handler.ts
import { APIError, Gateway, Header } from "encore.dev/api";
import { authHandler } from "encore.dev/auth";
import { db } from "./db";
import { session, user } from "./schema";
import { eq } from "drizzle-orm";
import log from "encore.dev/log";
// Define what we extract from the Authorization header
interface AuthParams {
authorization: Header<"Authorization">;
}
// Define what authenticated data we make available to endpoints
interface AuthData {
userID: string;
email: string;
name: string;
}
const myAuthHandler = authHandler(
async (params: AuthParams): Promise<AuthData> => {
const token = params.authorization.replace("Bearer ", "");
if (!token) {
throw APIError.unauthenticated("no token provided");
}
try {
// Query the session directly from the database using Drizzle
// BetterAuth's getSession() is designed for cookie-based web apps,
// so for REST API bearer tokens we validate by querying the session table
const sessionRows = await db
.select({
userId: session.userId,
expiresAt: session.expiresAt,
})
.from(session)
.where(eq(session.token, token))
.limit(1);
const sessionRow = sessionRows[0];
if (!sessionRow) {
throw APIError.unauthenticated("invalid session");
}
// Check if session is expired
if (new Date(sessionRow.expiresAt) < new Date()) {
throw APIError.unauthenticated("session expired");
}
// Get user info
const userRows = await db
.select({
id: user.id,
email: user.email,
name: user.name,
})
.from(user)
.where(eq(user.id, sessionRow.userId))
.limit(1);
const userRow = userRows[0];
if (!userRow) {
throw APIError.unauthenticated("user not found");
}
return {
userID: userRow.id,
email: userRow.email,
name: userRow.name,
};
} catch (e) {
log.error(e);
throw APIError.unauthenticated("invalid token", e as Error);
}
}
);
// Create gateway with auth handler
export const gateway = new Gateway({ authHandler: myAuthHandler });
Creating protected endpoints
Let’s build a profile service to demonstrate how authentication works with protected endpoints. Any endpoint marked with auth: true will automatically require authentication, and you can access the authenticated user’s information using the getAuthData() function throughout your application:
// profile/encore.service.ts
import { Service } from "encore.dev/service";
export default new Service("profile");
// profile/profile.ts
import { api } from "encore.dev/api";
import { getAuthData } from "~encore/auth";
import log from "encore.dev/log";
interface UserProfile {
id: string;
email: string;
name: string;
}
export const getProfile = api(
{
expose: true,
auth: true, // Requires authentication
method: "GET",
path: "/profile",
},
async (): Promise<UserProfile> => {
// Get authenticated user data from auth handler
const authData = getAuthData()!;
log.info("Profile accessed", { userID: authData.userID });
return {
id: authData.userID,
email: authData.email,
name: authData.name,
};
}
);
interface UpdateProfileRequest {
name: string;
}
export const updateProfile = api(
{
expose: true,
auth: true,
method: "PUT",
path: "/profile",
},
async (req: UpdateProfileRequest): Promise<UserProfile> => {
const authData = getAuthData()!;
log.info("Profile update", {
userID: authData.userID,
newName: req.name,
});
// In a real app, update the database here
// For now, just return the updated data
return {
id: authData.userID,
email: authData.email,
name: req.name,
};
}
);
Testing the backend
Start your Encore backend using the built-in development server (make sure Docker is running first):
encore run
Your API is now running locally with hot-reloading enabled. Encore automatically starts the PostgreSQL database in a Docker container and runs all migrations. Open the local development dashboard at http://localhost:9400 to explore your API with interactive documentation, view distributed traces for each request, and test endpoints directly in the browser.
Exploring the database: The local development dashboard includes a built-in database explorer powered by Drizzle Studio. You can browse your database tables, view user records and sessions, and run queries visually - perfect for debugging and understanding how BetterAuth stores authentication data.
Testing with curl
Sign up a new user:
curl -X POST http://localhost:4000/auth/signup \
-H "Content-Type: application/json" \
-d '{
"email": "user@example.com",
"password": "SecurePass123!",
"name": "John Doe"
}'
You’ll get a response with a session token:
{
"user": {
"id": "...",
"email": "user@example.com",
"name": "John Doe"
},
"session": {
"token": "eyJhbGci...",
"expiresAt": "2025-01-23T..."
}
}
Sign in:
curl -X POST http://localhost:4000/auth/signin \
-H "Content-Type: application/json" \
-d '{
"email": "user@example.com",
"password": "SecurePass123!"
}'
Access protected endpoint:
curl http://localhost:4000/profile \
-H "Authorization: Bearer YOUR_SESSION_TOKEN"
Update profile:
curl -X PUT http://localhost:4000/profile \
-H "Authorization: Bearer YOUR_SESSION_TOKEN" \
-H "Content-Type: application/json" \
-d '{"name": "Jane Doe"}'
Every request generates a detailed trace that shows the complete execution flow. Here’s what a trace looks like for the update profile request, showing the auth handler validation, database query, and response:
Using the API Explorer
Open the local development dashboard at http://localhost:9400 and you’ll see Encore’s built-in development tools:
- Service Catalog: All your API endpoints with auto-generated documentation
- Request/Response Schemas: Type-safe interfaces automatically extracted from your code
- API Testing: Test endpoints directly in the browser with a built-in API client
- Distributed Tracing: Visual timeline of each request showing database queries, service calls, and auth handler execution
- Authentication Status: See which endpoints require authentication and test them with bearer tokens
Try signing up a user through the API Explorer or curl, then use the returned session token to access the protected /profile endpoint. You’ll see the full request trace, including the auth handler execution and database queries.
Connecting to a frontend
One of Encore’s most powerful features is its ability to automatically generate type-safe API clients for your frontend. This ensures that your frontend and backend stay in sync with zero manual work. Generate the client for your frontend application:
encore gen client frontend/src/lib/client.ts
This creates a fully typed TypeScript client that matches your backend API exactly. Here’s how to use it in your frontend application:
import Client, { Local } from "./lib/client";
// Sign up
const client = new Client(Local);
const { user, session } = await client.auth.signUp({
email: "user@example.com",
password: "SecurePass123!",
name: "John Doe",
});
// Store session token (e.g., in localStorage or a cookie)
localStorage.setItem("authToken", session.token);
// Make authenticated requests
const authedClient = new Client(Local, {
auth: { authorization: `Bearer ${session.token}` },
});
const profile = await authedClient.profile.getProfile();
CORS Configuration:
When your frontend runs on a different origin (like localhost:5173 for Vite or localhost:3000 for Next.js), you need to configure CORS to allow authenticated requests. Update the encore.app file in your project root:
{
"id": "auth-app",
"global_cors": {
"allow_origins_with_credentials": ["http://localhost:5173"]
}
}
This tells Encore to accept authenticated requests from your frontend’s origin. In production, Encore automatically configures CORS based on your deployment settings.
For complete frontend integration guides, see the frontend integration documentation.
Deployment
Deploying your authentication backend with Encore is straightforward. Simply push your code:
git add .
git commit -m "Add BetterAuth authentication"
git push encore
Before your production deployment can run, set the production authentication secret:
# Generate a strong random secret for production
encore secret set --prod BetterAuthSecret
Note: Encore Cloud is great for prototyping and development with fair use limits (100k requests/day, 1GB database). For production workloads, you can connect your AWS or GCP account and Encore will provision and deploy infrastructure directly in your cloud account.
Advanced features
Adding OAuth providers
BetterAuth supports social login with popular OAuth providers like Google, GitHub, Discord, and many more. Here’s how to add Google OAuth authentication to your backend:
// auth/better-auth.ts
import { betterAuth } from "better-auth";
const googleClientId = secret("GoogleClientId");
const googleClientSecret = secret("GoogleClientSecret");
export const auth = betterAuth({
database: {
provider: "postgres",
url: DB.connectionString,
},
secret: authSecret(),
emailAndPassword: {
enabled: true,
},
socialProviders: {
google: {
clientId: googleClientId(),
clientSecret: googleClientSecret(),
redirectURI: "http://localhost:4000/auth/callback/google",
},
},
});
Email verification
For production applications, you’ll want to verify user email addresses before allowing them to access your application. Enable email verification in your BetterAuth configuration and integrate with an email service like Resend, SendGrid, or Amazon SES:
export const auth = betterAuth({
// ... other config
emailAndPassword: {
enabled: true,
requireEmailVerification: true,
},
emailVerification: {
sendVerificationEmail: async (user, url) => {
// Send email with verification link
// Integrate with Resend, SendGrid, etc.
},
},
});
Two-factor authentication
Enhance your application’s security by adding two-factor authentication (2FA). BetterAuth provides a plugin system that makes it easy to add TOTP-based 2FA using authenticator apps like Google Authenticator or Authy:
import { twoFactor } from "better-auth/plugins";
export const auth = betterAuth({
// ... other config
plugins: [
twoFactor({
issuer: "YourApp",
}),
],
});
Session management
Give your users visibility and control over their active sessions by adding endpoints to list and revoke sessions. This is especially important for security-conscious applications where users might want to sign out of all devices:
export const listSessions = api(
{ expose: true, auth: true, method: "GET", path: "/sessions" },
async () => {
const authData = getAuthData()!;
// Query sessions from database
const sessions = await DB.query`
SELECT id, created_at, ip_address, user_agent
FROM session
WHERE user_id = ${authData.userID}
ORDER BY created_at DESC
`;
return { sessions: Array.from(sessions) };
}
);
export const revokeSession = api(
{ expose: true, auth: true, method: "DELETE", path: "/sessions/:id" },
async ({ id }: { id: string }) => {
await auth.api.signOut({ body: { sessionId: id } });
return { success: true };
}
);
Next steps
Now that you have a fully functional authentication backend, here are some ways to extend and enhance it:
- Add role-based access control (RBAC) by extending the auth data with user roles and permissions, then check roles in your endpoints
- Implement email verification with services like Resend or SendGrid to confirm user email addresses
- Add OAuth providers (Google, GitHub, Discord, etc.) for social login to improve user experience
- Enable two-factor authentication (2FA) for enhanced security using BetterAuth’s 2FA plugin
- Add password reset functionality with secure email links and token expiration
- Implement organizations and teams using BetterAuth’s organization plugin for B2B applications
- Add session management features to let users view and revoke active sessions from different devices
- Integrate with Pub/Sub to send welcome emails asynchronously when users sign up using Encore’s Pub/Sub primitive
- Add API rate limiting to protect your auth endpoints from brute force attacks
- Implement refresh tokens for longer-lived authentication sessions
Conclusion
You’ve successfully built a complete, production-ready authentication backend with BetterAuth and Encore.ts! This combination gives you the best of both worlds:
BetterAuth handles the security-critical aspects of authentication (password hashing, session management, token generation, and OAuth integration) so you don’t have to worry about implementing these complex features from scratch.
Encore.ts provides the infrastructure foundation with type-safe APIs, automatic database provisioning, built-in secrets management, and seamless deployment. The auth handler pattern makes it trivial to protect any endpoint with a simple auth: true flag, while getAuthData() gives you type-safe access to authenticated user information throughout your application.
The auto-generated TypeScript client ensures complete type safety between your frontend and backend, catching errors at compile time rather than runtime. The local development dashboard provides full observability into authentication flows with distributed tracing, making debugging authentication issues straightforward.
Ready to learn more?
- Check out the Encore documentation to explore more features like Pub/Sub, Cron Jobs, and Object Storage
- Browse the example applications to see real-world implementations
- Join our Discord community to get help, share what you’re building, and connect with other developers
- Read the BetterAuth documentation to explore advanced authentication features