Backend Authentication & API Guide
Complete guide to authentication, tRPC procedures, database queries, and error handling
Table of Contents
Authentication & Security
Better Auth integration and role-based access control patterns.
Session Management
File: apps/api/src/lib/auth.ts
import { auth } from '@izri/auth'
import type { AuthenticatedUser } from '@izri/shared'
export async function getSession(request: Request): Promise<AuthenticatedUser | null> {
try {
const sessionData = await auth.api.getSession({ headers: request.headers })
if (!sessionData?.user) return null
return {
id: sessionData.user.id,
email: sessionData.user.email,
name: sessionData.user.name,
image: sessionData.user.image,
}
} catch (error) {
console.error('Session retrieval failed:', error)
return null
}
}
export async function requireSession(request: Request): Promise<AuthenticatedUser> {
const user = await getSession(request)
if (!user) throw new Error('Unauthorized')
return user
}
Better Auth Setup
File: apps/api/src/lib/better-auth.ts
import { betterAuth } from 'better-auth'
import { drizzleAdapter } from 'better-auth/adapters/drizzle'
import { db } from './db'
import * as schema from '@izri/database/schema'
export const auth = betterAuth({
database: drizzleAdapter(db, {
provider: 'pg',
schema,
}),
secret: process.env.BETTER_AUTH_SECRET,
basePath: '/api/auth',
trustedOrigins: [process.env.WEB_URL || 'http://localhost:3000'],
socialProviders: {
github: {
clientId: process.env.GITHUB_CLIENT_ID,
clientSecret: process.env.GITHUB_CLIENT_SECRET,
},
},
})
Session Middleware Integration
import { auth } from '@izri/auth'
import { createMiddleware } from 'hono/factory'
export const withSession = createMiddleware(async (c, next) => {
const sessionData = await auth.api.getSession({ headers: c.req.raw.headers })
if (sessionData?.user) {
c.set('user', sessionData.user)
}
await next()
})
Role-Based Access Control
export async function requireAdmin(request: Request, userId: string) {
const user = await requireSession(request)
const adminRole = await db.query.userRoles.findFirst({
where: (roles, { eq, and }) =>
and(
eq(roles.userId, userId),
eq(roles.role, 'ADMIN')
)
})
if (!adminRole) {
throw new Error('Admin privileges required')
}
return user
}
export async function requireOrganizationAccess(
request: Request,
organizationId: string,
userId: string
) {
const user = await requireSession(request)
const membership = await db.query.organizationMembers.findFirst({
where: (members, { eq, and }) =>
and(
eq(members.organizationId, organizationId),
eq(members.userId, userId)
)
})
if (!membership) {
throw new Error('Not a member of this organization')
}
return { user, membership }
}
Security Best Practices
CSRF Protection:
import { csrf } from 'hono/csrf'
app.use('*', csrf())
Rate Limiting:
import { RateLimiter } from 'some-rate-limiter'
const limiter = new RateLimiter()
app.use('/api/auth/login', limiter.middleware({ windowMs: 15 * 60 * 1000, max: 5 }))
HTTPS Only:
if (process.env.NODE_ENV === 'production') {
app.use('*', (c, next) => {
if (!c.req.header('X-Forwarded-Proto')?.includes('https')) {
return c.json({ error: 'HTTPS required' }, 403)
}
return next()
})
}
tRPC Server & Procedures
Server Setup
File: apps/api/src/trpc.ts
import { initTRPC } from '@trpc/server'
import type { AuthenticatedUser } from '@izri/shared'
interface Context {
user?: AuthenticatedUser
req: Request
}
const t = initTRPC.context<Context>().create()
export const router = t.router
export const publicProcedure = t.procedure
export const protectedProcedure = t.procedure.use(async ({ ctx, next }) => {
if (!ctx.user) {
throw new Error('UNAUTHORIZED')
}
return next({
ctx: {
user: ctx.user,
},
})
})
Procedure Types
Public Procedure:
export const publicRouter = router({
getPublicData: publicProcedure.query(async () => {
return { data: 'public' }
}),
})
Protected Procedure:
export const protectedRouter = router({
getProfile: protectedProcedure.query(async ({ ctx }) => {
return { user: ctx.user }
}),
})
Input Validation:
import { z } from 'zod'
import { ulid } from 'ulid'
export const authRouter = router({
createProject: protectedProcedure
.input(z.object({
name: z.string().min(1),
organizationId: z.string().min(1), // ULID string (26 chars)
}))
.mutation(async ({ input, ctx }) => {
const { name, organizationId } = input
const userId = ctx.user.id
// Verify user has access
const membership = await db.query.organizationMembers.findFirst({
where: (m, { eq, and }) =>
and(eq(m.organizationId, organizationId), eq(m.userId, userId))
})
if (!membership) throw new Error('UNAUTHORIZED')
// Create project with ULID
const project = await db.insert(projects).values({
id: ulid(),
name,
organizationId,
}).returning()
return project[0]
}),
})
Combining Routers
export const appRouter = router({
auth: authRouter,
projects: projectsRouter,
organizations: organizationsRouter,
})
export type AppRouter = typeof appRouter
Database Queries
Basic Operations
Find Single:
const user = await db.query.users.findFirst({
where: (users, { eq }) => eq(users.id, userId),
})
Find Many:
const projects = await db.query.projects.findMany({
where: (projects, { eq }) => eq(projects.organizationId, orgId),
limit: 10,
offset: 0,
})
Insert:
const result = await db.insert(users).values({
email: 'user@example.com',
name: 'John',
}).returning()
const newUser = result[0]
Update:
await db.update(users)
.set({ name: 'Jane' })
.where(eq(users.id, userId))
Delete:
await db.delete(users)
.where(eq(users.id, userId))
Complex Queries
With Joins:
const projectsWithOrg = await db.query.projects.findMany({
where: (projects, { eq }) => eq(projects.organizationId, orgId),
with: {
organization: true,
owner: {
columns: { id: true, name: true, email: true },
},
},
})
With Filtering:
const activeProjects = await db.query.projects.findMany({
where: (projects, { eq, and }) =>
and(
eq(projects.organizationId, orgId),
eq(projects.status, 'ACTIVE')
),
orderBy: (projects) => projects.createdAt,
})
Transactions:
await db.transaction(async (trx) => {
const org = await trx.insert(organizations).values({
name: 'New Org',
}).returning()
await trx.insert(organizationMembers).values({
organizationId: org[0].id,
userId: userId,
role: 'OWNER',
})
})
Query Optimization
Pagination:
export async function getProjectsPaginated(
organizationId: string,
page: number,
pageSize: number
) {
const offset = (page - 1) * pageSize
const [projects, total] = await Promise.all([
db.query.projects.findMany({
where: (p, { eq }) => eq(p.organizationId, organizationId),
limit: pageSize,
offset,
}),
db.query.projects.count({
where: (p, { eq }) => eq(p.organizationId, organizationId),
}),
])
return { projects, total, pages: Math.ceil(total / pageSize) }
}
Selective Columns:
const users = await db.query.users.findMany({
columns: { id: true, email: true, name: true },
where: (users, { eq }) => eq(users.organizationId, orgId),
})
Error Handling
Custom Error Classes
File: apps/api/src/lib/errors.ts
export class ApiError extends Error {
constructor(
public code: string,
public statusCode: number,
message: string,
public details?: Record<string, any>
) {
super(message)
this.name = 'ApiError'
}
}
export class ValidationError extends ApiError {
constructor(message: string, details?: Record<string, any>) {
super('VALIDATION_ERROR', 400, message, details)
}
}
export class AuthenticationError extends ApiError {
constructor(message: string = 'Authentication required') {
super('AUTHENTICATION_ERROR', 401, message)
}
}
export class AuthorizationError extends ApiError {
constructor(message: string = 'Insufficient permissions') {
super('AUTHORIZATION_ERROR', 403, message)
}
}
export class NotFoundError extends ApiError {
constructor(resource: string) {
super('NOT_FOUND', 404, `${resource} not found`)
}
}
Error Handling Middleware
import type { Context, Next } from 'hono'
export async function errorHandler(c: Context, next: Next) {
try {
await next()
} catch (error) {
if (error instanceof ApiError) {
return c.json(
{
error: {
code: error.code,
message: error.message,
details: error.details,
},
},
{ status: error.statusCode }
)
}
if (error instanceof Error) {
console.error('Unexpected error:', error)
return c.json(
{
error: {
code: 'INTERNAL_SERVER_ERROR',
message: 'An unexpected error occurred',
},
},
{ status: 500 }
)
}
return c.json(
{
error: {
code: 'UNKNOWN_ERROR',
message: 'Unknown error occurred',
},
},
{ status: 500 }
)
}
}
tRPC Error Handling
import { TRPCError } from '@trpc/server'
export const projectsRouter = router({
getById: protectedProcedure
.input(z.object({ id: z.string() }))
.query(async ({ input, ctx }) => {
const project = await db.query.projects.findFirst({
where: (p, { eq }) => eq(p.id, input.id),
})
if (!project) {
throw new TRPCError({
code: 'NOT_FOUND',
message: 'Project not found',
})
}
const hasAccess = await checkProjectAccess(ctx.user.id, project.id)
if (!hasAccess) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'You do not have access to this project',
})
}
return project
}),
})
Logging Errors
import { logger } from '@/lib/logger'
export async function logError(error: unknown, context?: Record<string, any>) {
if (error instanceof ApiError) {
logger.warn(
{ ...context, code: error.code, details: error.details },
error.message
)
} else if (error instanceof Error) {
logger.error(
{ ...context, stack: error.stack },
error.message
)
} else {
logger.error({ ...context }, String(error))
}
}
Related: Setup & Configuration | Testing | README