Skip to main content

Quick start

WIP

Password and magic-link sign-in behind Express, stored in Postgres through Drizzle, from install to sign-out

This guide builds a small Express backend: sign-up, password and magic-link sign-in, the session endpoint and sign-out, stored in Postgres. Every file is shown whole.

Install

bun add @gentleduck/auth drizzle-orm pg express@5
bun add -D drizzle-kit @types/pg @types/express

Create the tables

duck-auth ships its Drizzle tables. Re-export them from your schema so drizzle-kit sees them next to your own.

src/db/schema.ts
export {
  authCredentials,
  authIdentities,
  authIdentityProviders,
  authSessions,
} from '@gentleduck/auth/adapters/drizzle/pg'
drizzle.config.ts
import { defineConfig } from 'drizzle-kit'

export default defineConfig({
  dialect: 'postgresql',
  schema: './src/db/schema.ts',
  out: './drizzle',
  dbCredentials: { url: process.env.DATABASE_URL ?? '' },
})
bunx drizzle-kit push

push suits a first run; switch to drizzle-kit generate and a migrator once the schema is shared.

Build the engine

drizzlePgAdapter(db) provides the identities, sessions and credentials stores. The magic-link provider is passed as a thunk so it receives the engine's deliver; here deliver prints the link instead of mailing it.

src/auth.ts
import { drizzlePgAdapter } from '@gentleduck/auth/adapters/drizzle/pg'
import { createAuth, type Deliver, orNull } from '@gentleduck/auth/core'
import { cookieTransport } from '@gentleduck/auth/core/transport'
import { MemoryLimiter } from '@gentleduck/auth/limiters/memory'
import { magicLink } from '@gentleduck/auth/providers/magic-link'
import { passwords } from '@gentleduck/auth/providers/passwords'
import { drizzle } from 'drizzle-orm/node-postgres'
import { Pool } from 'pg'
import * as schema from './db/schema'

const db = drizzle(new Pool({ connectionString: process.env.DATABASE_URL }), { schema })
const stores = drizzlePgAdapter(db)

const deliver: Deliver = async ({ identity, kind, vars }) => {
  console.log(`[${kind}] ${identity.profile.email}: ${vars.url}`)
}

export const auth = createAuth({
  baseUrl: 'http://localhost:3000',
  deliver,
  limiter: new MemoryLimiter(),
  stores,
  transport: cookieTransport({ name: 'sid', secure: false }),
  providers: [
    passwords(),
    (_engine, send) =>
      magicLink({
        callbackPath: '/magic-link',
        deliver: send,
        findIdentityByEmail: (email) => orNull(stores.identities.find({ email })),
      }),
  ],
})

Write the routes

The routes are yours, over auth.flows. server/express supplies the glue: the CSRF guard, the caller, and applyIntents to write a flow's answer. Sign-up is yours too, since what an account needs (a name, terms accepted, an invite) is your app's call.

src/server.ts
import { AuthError } from '@gentleduck/auth/core'
import { errorToHttp } from '@gentleduck/auth/server/generic'
import { applyIntents, expressCaller, expressCsrf, toHeaders } from '@gentleduck/auth/server/express'
import express, { type ErrorRequestHandler } from 'express'
import { auth } from './auth'

const app = express()
app.use(express.json())
app.use(expressCsrf(auth))

app.post('/auth/signin', async (req, res) => {
  const { providerId, input } = req.body ?? {}
  if (typeof providerId !== 'string') throw new AuthError('AUTH_INVALID_PARAMETERS')
  const headers = toHeaders(req.headers)
  const { intents } = await auth.flows.signIn({
    input: input ?? {},
    providerId,
    ...expressCaller(req),
    previousSid: auth.transport.extract({ headers }) ?? undefined,
  })
  applyIntents(intents, res)
})

app.post('/auth/signout', async (req, res) => {
  const sid = auth.transport.extract({ headers: toHeaders(req.headers) })
  applyIntents(sid ? (await auth.flows.signOut(sid)).intents : auth.transport.revoke(), res)
})

app.get('/auth/session', async (req, res) => {
  const resolved = await auth.resolveSession({ headers: toHeaders(req.headers) }).orNull()
  if (!resolved) return applyIntents([{ type: 'json', status: 200, body: { session: null, identity: null } }], res)
  // `csrfHash` is server-side state; the browser holds the plaintext
  const { csrfHash: _csrfHash, ...session } = resolved.session
  applyIntents([{ type: 'json', status: 200, body: { session, identity: resolved.identity } }], res)
})

app.post('/auth/signup', async (req, res) => {
  const { email, name, password } = req.body
  auth.passwords.assertStrength(password)
  const identity = await auth.identities.create({ profile: { email, name, username: email } })
  await auth.passwords.set(identity.id, password, auth.cfg.stores.credentials)
  res.status(201).json({ identityId: identity.id })
})

const onError: ErrorRequestHandler = (err, _req, res, _next) => {
  const { status, body } = errorToHttp(err)
  res.status(status).json(body)
}
app.use(onError)

app.listen(3000)

errorToHttp maps an AuthError to its status and an { ok: false, error: { code, status } } body, and anything else to a 500 that says nothing about the cause. Express 5, which the install pins, hands an async route's throw to onError; Express 4 leaves it unhandled. assertStrength refuses a weak password as AUTH_INVALID_CREDENTIALS without naming the rule it broke.

Try it

DATABASE_URL=postgres://localhost/app bun src/server.ts

Sign up, then sign in; -c and -b keep the cookies between calls.

curl -s localhost:3000/auth/signup -H 'content-type: application/json' \
  -d '{"email":"ada@example.com","name":"Ada","password":"correct-horse-battery"}'

curl -s -c jar localhost:3000/auth/signin -H 'content-type: application/json' \
  -d '{"providerId":"password","input":{"email":"ada@example.com","password":"correct-horse-battery"}}'

curl -s -b jar localhost:3000/auth/session

The sign-in answers 200 with the two cookies, the session call answers { session, identity }, and a wrong password answers 401 AUTH_INVALID_CREDENTIALS. Signing out needs the CSRF token, which the sign-in set in the duck-csrf cookie:

curl -s -b jar localhost:3000/auth/signout -X POST \
  -H "x-csrf-token: $(awk '$6 == "duck-csrf" { print $7 }' jar)"

For a magic link, begin the provider with its input as the body; the server prints the link:

curl -s localhost:3000/auth/providers/magic-link/begin -H 'content-type: application/json' \
  -d '{"email":"ada@example.com"}'

The link opens /magic-link?token=... on baseUrl. That page posts the token back to sign in:

curl -s -c jar localhost:3000/auth/signin -H 'content-type: application/json' \
  -d '{"providerId":"magic-link","input":{"token":"<token from the link>"}}'

Next steps