
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.
BetterAuth is a comprehensive TypeScript authentication framework designed for modern web applications. It provides:
BetterAuth provides a complete authentication solution out of the box, from password hashing to OAuth integration.
We'll create a backend authentication system with:
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.
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
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.
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
);
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");
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.
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 };
}
);
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 });
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,
};
}
);
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.

Sign up a new user:
curl -X POST http://localhost:4000/auth/signup \
-H "Content-Type: application/json" \
-d '{
"email": "[email protected]",
"password": "SecurePass123!",
"name": "John Doe"
}'
You'll get a response with a session token:
{
"user": {
"id": "...",
"email": "[email protected]",
"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": "[email protected]",
"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:

Open the local development dashboard at http://localhost:9400 and you'll see Encore's built-in development tools:
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.

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: "[email protected]",
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.
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.
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",
},
},
});
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.
},
},
});
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",
}),
],
});
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 };
}
);
Now that you have a fully functional authentication backend, here are some ways to extend and enhance it:
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?


