Quick start
WIPPassword 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.
export {
authCredentials,
authIdentities,
authIdentityProviders,
authSessions,
} from '@gentleduck/auth/adapters/drizzle/pg'
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.
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.
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
- Delivery: replace the
console.logwith a mailer. - Server adapters: what each of these routes must check, and the same routes on other frameworks.
- Adding MFA: TOTP and backup codes as a second step.
- Production hardening: what
strict()checks onceNODE_ENV=production.