Add sign-in, end to end
Users, password sign-in, cookie sessions, guarded pages, sessions in server actions and API routes, and permissions checked the same way in the browser and on the server.
Sign-in touches every layer of an app at once: a table of users, a sign-in endpoint, a cookie, a guard on the pages, a check in every server action and API route, and buttons that only some people should see. Each piece is small. The work is making them agree, so that the rule that hides the Edit button is the same rule the server enforces.
This guide adds that to acme-projects, the app from Build a full-stack app. Ada (an admin) and Grace (a member) sign in with an email and password, the session lives in an HTTP-only cookie, every page under /projects needs a signed-in user, server actions read the user's team from the session instead of trusting the browser, and members can only rename the projects they own.
Before you start
You need the project from Build a full-stack app, with its Postgres database and Drizzle schema. The fullstack kit already includes Auth: config/auth.ts, app/abilities.ts and the session in the router context exist. In another project, run kitr add auth first.
kitr does not store passwords or run an identity server; Auth connects to whatever does. This guide writes the few endpoints a password sign-in needs, in your own API, so you can see every moving part. To use a hosted provider or Better Auth instead, swap the adapter in "Connect the browser to the session"; everything after that section stays the same.
Store users and sessions
Add two tables next to projects. A session is a random id the server can look up and delete, so signing out really ends it:
// server/db/schema.ts
import { pgTable, text, timestamp } from 'drizzle-orm/pg-core'
export const users = pgTable('users', {
id: text('id').primaryKey(),
email: text('email').notNull().unique(),
passwordHash: text('password_hash').notNull(),
role: text('role', { enum: ['admin', 'member'] }).notNull(),
teamId: text('team_id').notNull(),
})
export const sessions = pgTable('sessions', {
id: text('id').primaryKey(),
userId: text('user_id')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
expiresAt: timestamp('expires_at').notNull(),
})
export const projects = pgTable('projects', {
id: text('id').primaryKey(),
teamId: text('team_id').notNull(),
ownerId: text('owner_id').notNull(),
name: text('name').notNull(),
createdAt: timestamp('created_at').defaultNow().notNull(),
})
export type Project = typeof projects.$inferSelect
projects gains an ownerId, which the permission rules use later. Generate and apply the migration:
pnpm drizzle-kit generate
pnpm drizzle-kit migrate
Hash passwords
Never store a password, only a slow hash of it. Node's built-in scrypt is enough, with no dependency:
// server/auth/passwords.ts
import { randomBytes, scrypt, timingSafeEqual } from 'node:crypto'
import { promisify } from 'node:util'
const scryptAsync = promisify(scrypt) as (password: string, salt: Buffer, keylen: number) => Promise<Buffer>
export async function hashPassword(password: string): Promise<string> {
const salt = randomBytes(16)
const hash = await scryptAsync(password, salt, 64)
return `${salt.toString('hex')}:${hash.toString('hex')}`
}
export async function verifyPassword(password: string, stored: string): Promise<boolean> {
const [salt, hash] = stored.split(':')
if (!salt || !hash) return false
const candidate = await scryptAsync(password, Buffer.from(salt, 'hex'), 64)
return timingSafeEqual(candidate, Buffer.from(hash, 'hex'))
}
timingSafeEqual compares in constant time, so an attacker cannot learn how much of a hash matched from how long the answer took.
Seed Ada and Grace so there is someone to sign in as:
// server/db/seed.ts
import { drizzle } from 'drizzle-orm/postgres-js'
import postgres from 'postgres'
import { hashPassword } from '#server/auth/passwords'
import { users } from '#server/db/schema'
const db = drizzle(postgres(process.env.DATABASE_URL!))
const passwordHash = await hashPassword('correct horse battery')
await db
.insert(users)
.values([
{ id: 'u_ada', email: '[email protected]', passwordHash, role: 'admin', teamId: 'team_acme' },
{ id: 'u_grace', email: '[email protected]', passwordHash, role: 'member', teamId: 'team_acme' },
])
.onConflictDoNothing()
await db.$client.end()
pnpm tsx --env-file=.env server/db/seed.ts
Create and look up sessions
Keep everything that touches the session cookie in one module, so the API, the actions and the tests all read it the same way:
// server/auth/sessions.ts
import { randomBytes } from 'node:crypto'
import { and, eq, gt } from 'drizzle-orm'
import { useDb } from 'kitr/db'
import { useRequestApp } from 'kitr/server'
import { app } from '#app/app'
import type { User } from '#app/abilities'
import type { AppDb } from '#config/db'
import { sessions, users } from '#server/db/schema'
export const SESSION_COOKIE = 'acme_session'
const THIRTY_DAYS = 30 * 24 * 60 * 60
const db = () => useDb<AppDb>(useRequestApp(app))
export async function startSession(userId: string): Promise<string> {
const id = randomBytes(32).toString('base64url')
await db().insert(sessions).values({ id, userId, expiresAt: new Date(Date.now() + THIRTY_DAYS * 1000) })
return id
}
export async function endSession(id: string): Promise<void> {
await db().delete(sessions).where(eq(sessions.id, id))
}
export async function findUserBySession(id: string): Promise<User | null> {
const [row] = await db()
.select({ id: users.id, email: users.email, role: users.role, teamId: users.teamId })
.from(sessions)
.innerJoin(users, eq(users.id, sessions.userId))
.where(and(eq(sessions.id, id), gt(sessions.expiresAt, new Date())))
return row ?? null
}
export function sessionCookie(id: string, maxAge = THIRTY_DAYS): string {
const secure = process.env.NODE_ENV === 'production' ? '; Secure' : ''
return `${SESSION_COOKIE}=${id}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${maxAge}${secure}`
}
export function sessionIdFrom(request: Request): string | null {
const cookies = request.headers.get('cookie') ?? ''
const match = cookies.match(new RegExp(`(?:^|;\\s*)${SESSION_COOKIE}=([^;]+)`))
return match ? decodeURIComponent(match[1]!) : null
}
What each cookie attribute does:
| Attribute | Effect |
|---|---|
HttpOnly |
Scripts on the page cannot read the cookie, so a cross-site scripting bug cannot steal the session. |
SameSite=Lax |
The browser does not send the cookie on form posts and fetch calls from other sites, which blocks cross-site request forgery on writes. |
Secure |
Only sent over HTTPS. Set in production; localhost is plain HTTP in development. |
Max-Age |
How long the browser keeps it. The expiresAt row is what the server trusts. |
Update app/abilities.ts so User matches the columns, and Project has an owner:
// app/abilities.ts
import { defineAbilities } from 'kitr/auth'
export interface User {
id: string
email: string
role: 'admin' | 'member'
teamId: string
}
export interface Project {
id: string
ownerId: string
teamId: string
}
export const abilities = defineAbilities({
'projects.view': (user: User, project: Project) => project.teamId === user.teamId,
'projects.update': (user: User, project: Project) => user.role === 'admin' || project.ownerId === user.id,
'teams.invite': (user: User) => user.role === 'admin',
})
Add the sign-in endpoints
Three routes are enough: start a session, read it, end it. Put them in a controller:
// server/controllers/session_controller.ts
import { eq } from 'drizzle-orm'
import type { HttpContext } from 'kitr/api'
import { KitrError } from 'kitr/app'
import { currentUser } from 'kitr/auth/server'
import { useDb } from 'kitr/db'
import { z } from 'zod'
import type { User } from '#app/abilities'
import type { AppDb } from '#config/db'
import { verifyPassword } from '#server/auth/passwords'
import { endSession, sessionCookie, sessionIdFrom, startSession } from '#server/auth/sessions'
import { users } from '#server/db/schema'
const credentials = z.object({ email: z.string().email(), password: z.string().min(1) })
export default class SessionController {
static validation = { store: { body: credentials } }
async show(ctx: HttpContext) {
return { user: currentUser<User>(ctx) }
}
async store({ app, body, response }: HttpContext<{}, {}, z.output<typeof credentials>>) {
const [user] = await useDb<AppDb>(app).select().from(users).where(eq(users.email, body.email.toLowerCase()))
if (!user || !(await verifyPassword(body.password, user.passwordHash)))
throw KitrError.unauthorized('Wrong email or password')
response.header('set-cookie', sessionCookie(await startSession(user.id)))
return { user: { id: user.id, email: user.email, role: user.role, teamId: user.teamId } }
}
async destroy({ request, response }: HttpContext) {
const id = sessionIdFrom(request.native)
if (id) await endSession(id)
response.header('set-cookie', sessionCookie('', 0))
return response.noContent()
}
}
The same message for an unknown email and a wrong password is deliberate: a different answer would tell an attacker which emails have accounts.
Register the routes, and read the cookie on every API request with authMiddleware from kitr/auth/server:
// server/routes.ts
import { defineRoutes } from 'kitr/api'
import { authMiddleware, can } from 'kitr/auth/server'
import { abilities, type User } from '#app/abilities'
import { findUserBySession, SESSION_COOKIE } from '#server/auth/sessions'
import ProjectsController from '#server/controllers/projects_controller'
import SessionController from '#server/controllers/session_controller'
import { findProject } from '#server/services/projects'
const optionalAuth = authMiddleware<User>({
from: { cookie: SESSION_COOKIE },
optional: true,
verify: (id) => findUserBySession(id),
abilities,
})
const auth = authMiddleware<User>({ from: { cookie: SESSION_COOKIE }, verify: (id) => findUserBySession(id), abilities })
export const routes = defineRoutes((router) =>
router.group({ prefix: '/api' }, (api) =>
api
.get('/health', () => ({ ok: true }), { name: 'health' })
.group({ middleware: [optionalAuth] }, (open) =>
open
.get('/session', [SessionController, 'show'], { name: 'session.show' })
.post('/session', [SessionController, 'store'], { name: 'session.store' })
.delete('/session', [SessionController, 'destroy'], { name: 'session.destroy' }),
)
.group({ middleware: [auth] }, (secured) =>
secured
.get('/projects', [ProjectsController, 'index'], { name: 'projects.index' })
.patch('/projects/:id', [ProjectsController, 'update'], {
name: 'projects.update',
middleware: [can('projects.update', (ctx) => findProject(ctx.params.id))],
}),
),
),
)
export type AppRoutes = typeof routes.$types
can('projects.update', loader) loads the project before it checks the rule. The loader is a plain function:
// server/services/projects.ts
import { eq } from 'drizzle-orm'
import { useDb } from 'kitr/db'
import { useRequestApp } from 'kitr/server'
import { app } from '#app/app'
import type { AppDb } from '#config/db'
import { projects } from '#server/db/schema'
export async function findProject(id: string) {
const [project] = await useDb<AppDb>(useRequestApp(app)).select().from(projects).where(eq(projects.id, id))
return project ?? null
}
Try it from a terminal:
curl -i -X POST localhost:3000/api/session -H 'content-type: application/json' \
-d '{"email":"[email protected]","password":"correct horse battery"}'
HTTP/1.1 200 OK
set-cookie: acme_session=Yp3...; Path=/; HttpOnly; SameSite=Lax; Max-Age=2592000
{"user":{"id":"u_grace","email":"[email protected]","role":"member","teamId":"team_acme"}}
GET /api/projects without the cookie answers 401 with {"code":"UNAUTHORIZED","message":"Authentication required","status":401}. Grace renaming one of Ada's projects through PATCH /api/projects/:id answers 403 with Missing permission "projects.update", before the controller runs.
Connect the browser to the session
The browser side of Auth asks an adapter for the session. With a cookie the browser sends on its own, customAdapter only needs to call the three routes:
// config/auth.ts
import { createSession, customAdapter, defineConfig } from 'kitr/auth'
import { KitrError } from 'kitr/app'
import { abilities, type User } from '#app/abilities'
type Credentials = { email: string; password: string }
const adapter = customAdapter<User, Credentials>({
async getSession() {
const res = await fetch('/api/session', { credentials: 'same-origin' })
const { user } = (await res.json()) as { user: User | null }
return user ? createSession(user, abilities) : null
},
async signIn(credentials) {
const res = await fetch('/api/session', {
method: 'POST',
credentials: 'same-origin',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(credentials),
})
if (!res.ok) throw KitrError.unauthorized('Wrong email or password')
const { user } = (await res.json()) as { user: User }
return createSession(user, abilities)
},
async signOut() {
await fetch('/api/session', { method: 'DELETE', credentials: 'same-origin' })
},
})
export default defineConfig({ adapter, signInPath: '/sign-in' })
The pages and the API share an origin, because app/routes/api.$.ts serves /api/* from the site's process, so the relative URL works and the cookie goes along. No token is stored anywhere a script can read.
Build the sign-in page
The page validates the form, calls auth.signIn and goes back to where the visitor was headed:
// app/routes/sign-in.tsx
import { createFileRoute } from '@tanstack/react-router'
import { useAuth } from 'kitr/auth'
import { FieldInput, Form, FormField, SubmitButton, useSchemaForm } from 'kitr/forms'
import { useNavigate } from 'kitr/router'
import { z } from 'zod'
import type { User } from '#app/abilities'
const schema = z.object({
email: z.string().email('Enter your work email'),
password: z.string().min(1, 'Enter your password'),
})
export const Route = createFileRoute('/sign-in')({
validateSearch: (search: Record<string, unknown>) => ({
redirect: typeof search.redirect === 'string' ? search.redirect : undefined,
}),
component: SignInPage,
})
function SignInPage() {
const { redirect } = Route.useSearch()
const navigate = useNavigate()
const auth = useAuth<User, z.output<typeof schema>>()
const form = useSchemaForm(schema, { defaultValues: { email: '', password: '' } })
return (
<main className="mx-auto grid max-w-sm gap-4 p-6">
<h1 className="text-2xl font-semibold">Sign in to Acme</h1>
<Form
form={form}
onSubmit={async (values) => {
await auth.signIn(values)
await navigate({ to: redirect ?? '/projects' })
}}
className="grid gap-3"
>
<FormField name="email" label="Email">
<FieldInput type="email" autoComplete="email" placeholder="[email protected]" />
</FormField>
<FormField name="password" label="Password">
<FieldInput type="password" autoComplete="current-password" />
</FormField>
<SubmitButton pendingText="Signing in…">Sign in</SubmitButton>
</Form>
</main>
)
}
A wrong password shows "Wrong email or password" at the top of the form: <Form> displays the KitrError the adapter threw. Only redirect to paths inside your app; redirect comes from the URL, so check it starts with / if you ever accept it from other places.
Protect the pages
Move the pages that need a user under a pathless layout, _app, and guard it once:
// app/routes/_app.tsx
import { createFileRoute, Outlet } from '@tanstack/react-router'
import type { Session } from 'kitr/auth'
import { requireAuth } from 'kitr/router'
import { ssrRoute } from 'kitr/seo'
import type { User } from '#app/abilities'
export const Route = createFileRoute('/_app')({
...ssrRoute(false),
beforeLoad: async ({ context, location }) => {
await context.app.boot()
const session = context.app.container.use('auth').session as Session<User>
return requireAuth()({ context: { session }, location })
},
component: () => <Outlet />,
})
app/routes/
├── __root.tsx
├── index.tsx
├── sign-in.tsx
├── _app.tsx # the guard
└── _app/
└── projects/
└── index.tsx # still answers /projects
An anonymous visitor to /projects lands on /sign-in?redirect=/projects. ssrRoute(false) renders the guarded pages in the browser: the server renders the shell, then the browser restores the session from the cookie and runs the guard. That keeps signed-in pages out of shared caches, and it means the guard and the session always run in the same place.
The guard is a convenience for the visitor, not security: the data behind the page is protected by the action and API checks below.
Read the session in server actions
A server action is an HTTP endpoint like any other, so it checks the session itself. Write one middleware that reads the cookie from the request, and use it with requireSession:
// app/actions/middleware/session.ts
import { getRequest } from '@tanstack/react-start/server'
import { createMiddleware } from 'kitr/actions'
import { findUserBySession, sessionIdFrom } from '#server/auth/sessions'
export const sessionMiddleware = createMiddleware({ type: 'function' }).server(async ({ next }) => {
const id = sessionIdFrom(getRequest())
const user = id ? await findUserBySession(id) : null
return next({ context: { session: user ? { user } : null } })
})
Now the action takes the team from the session, not from the browser. Before, projects.list accepted any teamId, so anyone could read another team's projects by changing one string:
// app/actions/projects/list.ts
import { desc, eq } from 'drizzle-orm'
import { defineQuery, requireSession } from 'kitr/actions'
import { useDb } from 'kitr/db'
import { useRequestApp } from 'kitr/server'
import { sessionMiddleware } from '#app/actions/middleware/session'
import { app } from '#app/app'
import type { AppDb } from '#config/db'
import { projects } from '#server/db/schema'
export const listProjects = defineQuery('projects.list', {
middleware: [sessionMiddleware, requireSession],
handler: ({ context }) =>
useDb<AppDb>(useRequestApp(app))
.select()
.from(projects)
.where(eq(projects.teamId, context.session.user.teamId))
.orderBy(desc(projects.createdAt)),
})
The page calls it with no input: useSuspenseQuery('projects.list'). Called without a session cookie, the action rejects with UNAUTHORIZED, and the query shows its error state.
Mutations check permissions the same way. Load the row, then ask the same abilities the browser uses:
// app/actions/projects/rename.ts
import { eq } from 'drizzle-orm'
import { defineMutation, requireSession } from 'kitr/actions'
import { KitrError } from 'kitr/app'
import { useDb } from 'kitr/db'
import { useRequestApp } from 'kitr/server'
import { z } from 'zod'
import { abilities } from '#app/abilities'
import { sessionMiddleware } from '#app/actions/middleware/session'
import { app } from '#app/app'
import type { AppDb } from '#config/db'
import { projects } from '#server/db/schema'
export const renameProject = defineMutation('projects.rename', {
middleware: [sessionMiddleware, requireSession],
input: z.object({ id: z.string(), name: z.string().trim().min(2) }),
effects: { invalidate: [['action', 'projects.list']] },
handler: async ({ input, context }) => {
const db = useDb<AppDb>(useRequestApp(app))
const [project] = await db.select().from(projects).where(eq(projects.id, input.id))
if (!project || !abilities.can(context.session.user, 'projects.view', project)) throw KitrError.notFound('Project')
if (!abilities.can(context.session.user, 'projects.update', project)) throw KitrError.forbidden()
const [renamed] = await db.update(projects).set({ name: input.name }).where(eq(projects.id, input.id)).returning()
return renamed!
},
})
A project in another team answers "not found", not "forbidden": the caller should not learn that it exists.
Show only what the user can do
In components, useCan() asks the same rules for the signed-in user. Hide the actions a user cannot take:
// app/features/projects/project-row.tsx
import { useCan } from 'kitr/auth'
import type { Project } from '#server/db/schema'
export function ProjectRow({ project, onRename }: { project: Project; onRename: () => void }) {
const can = useCan()
return (
<li className="flex items-center justify-between">
{project.name}
{can('projects.update', project) && (
<button type="button" onClick={onRename}>
Rename
</button>
)}
</li>
)
}
Grace sees Rename on her own projects; Ada sees it on every project. If Grace calls projects.rename on Ada's project anyway, from the console, the action answers FORBIDDEN. One rule, enforced in both places, written once in app/abilities.ts.
Show who is signed in, with a way out, in the layout:
// app/components/account-menu.tsx
import { useAuth, useUser } from 'kitr/auth'
import { useNavigate } from 'kitr/router'
import type { User } from '#app/abilities'
export function AccountMenu() {
const auth = useAuth()
const user = useUser<User>()
const navigate = useNavigate()
if (!user) return null
return (
<button
type="button"
onClick={async () => {
await auth.signOut()
await navigate({ to: '/sign-in' })
}}
>
Sign out {user.email}
</button>
)
}
Signing out deletes the session row, so the cookie stops working everywhere, not only in this tab.
Test it
Test the rules, the sign-in route and the guard on an action. The API test signs in through the real route and reuses the cookie:
// @vitest-environment node
// test/sign-in.test.ts
import { createTestClient } from 'kitr/testing'
import { describe, expect, it } from 'vitest'
import { server } from '#server/main'
const client = createTestClient(server)
async function signIn(email: string) {
const res = await client.post('/api/session', { email, password: 'correct horse battery' })
const cookie = res.headers.get('set-cookie')?.split(';')[0]
return createTestClient(server, { headers: { cookie: cookie ?? '' } })
}
describe('sign-in', () => {
it('rejects a wrong password with the same message as an unknown email', async () => {
const wrong = await client.post('/api/session', { email: '[email protected]', password: 'nope' })
const unknown = await client.post('/api/session', { email: '[email protected]', password: 'nope' })
expect(wrong.body).toEqual(unknown.body)
expect(wrong.status).toBe(401)
})
it('keeps members out of other people’s projects', async () => {
const grace = await signIn('[email protected]')
const res = await grace.patch('/api/projects/prj_ada_roadmap', { name: 'Mine now' })
expect(res.status).toBe(403)
expect(res.body).toMatchObject({ code: 'FORBIDDEN' })
})
})
Server actions run without middleware in tests, so pass the session as context:
// app/actions/projects/rename.test.ts
import { beforeAll, expect, it } from 'vitest'
import { renameProject } from '#app/actions/projects/rename'
import { app } from '#app/app'
beforeAll(() => app.boot())
const grace = { id: 'u_grace', email: '[email protected]', role: 'member' as const, teamId: 'team_acme' }
it('refuses to rename a project Grace does not own', async () => {
await expect(
renameProject.run({ id: 'prj_ada_roadmap', name: 'Mine now' }, { context: { session: { user: grace } } }),
).rejects.toMatchObject({ code: 'FORBIDDEN' })
})
Both tests expect a project prj_ada_roadmap owned by Ada in the test database; create it in a beforeEach. Test your app sets up that database and the seed.
Before you go to production
- Rate-limit
POST /api/session, so nobody can try passwords as fast as your server answers.rateLimit({ max: 10 })fromkitr/apion that route allows ten attempts a minute per IP. - Serve over HTTPS only. The cookie is
Securein production and the browser will not send it over plain HTTP. - Delete expired sessions on a schedule, such as a nightly
delete from sessions where expires_at < now(). - Let users reset a forgotten password. That needs an email: Mail sends it, with a single-use token stored like a session.
Next steps
- Auth: adapters, token sessions, refresh, and every hook and guard.
- Actions: middleware and errors in server actions.
- API routes: route groups, middleware and rate limits.
- Shield: CSRF tokens, security headers and CORS when the API serves other origins.
- Test your app: the test database these tests run against.