Docs /backend/authentication-and-api

Backend Authentication & API Guide

Complete guide to authentication, tRPC procedures, database queries, and error handling

Table of Contents

  1. Authentication & Security
  2. tRPC Server & Procedures
  3. Database Queries
  4. Error Handling

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

Reading this with an agent? /docs/backend/authentication-and-api.md serves the raw markdown.

All docs