Skip to main content

Quick start

WIP

Wire password + magic-link end-to-end on Express, sign-in to sign-out, in 15 minutes.

We'll build a minimal but production-shaped auth setup: Express, a Drizzle-backed SQL adapter, password and magic-link providers, the React client. Estimated time: 15 minutes.

Install

bun add @gentleduck/auth
bun add drizzle-orm pg @node-rs/argon2 resend
bun add -D @types/pg drizzle-kit

Mint the JWT signing material

bunx @gentleduck/auth keys generate hs256
# -> emits JWT_HS256_SECRET and JWT_HS256_KID to copy into .env

Emit the migration

bunx @gentleduck/auth migrate pg > drizzle/0001_auth.sql
psql $DATABASE_URL < drizzle/0001_auth.sql

Wire AuthEngine

src/lib/auth.ts
import { drizzle } from 'drizzle-orm/node-postgres'
import { Pool } from 'pg'
import { AuthArgon2idHasher, AuthEngine } from '@gentleduck/auth/core'
import { CookieTransport } from '@gentleduck/auth/core/transport'
import { createDrizzlePgBridge } from '@gentleduck/auth/adapters/drizzle/pg'
import { authCreateSqlStores } from '@gentleduck/auth/adapters/sql'
import { AuthMemoryLimiter } from '@gentleduck/auth/limiters/memory'
import { magicLink } from '@gentleduck/auth/providers/magic-link'
import { password } from '@gentleduck/auth/providers/password'
import { AuthResendChannel } from '@gentleduck/auth/channels/resend'
import { schema } from './db/schema'

const pool = new Pool({ connectionString: process.env.DATABASE_URL })
const db = drizzle(pool)
const bridge = createDrizzlePgBridge(db, schema)
const sql = authCreateSqlStores({ bridge })

export const auth = new AuthEngine({
  baseUrl: 'http://localhost:3000',
  transport: new CookieTransport({ secure: false, name: 'duck-sid' }),
  stores: {
    identities: sql.identities,
    credentials: sql.credentials,
    sessions: sql.sessions,
  },
  limiter: new AuthMemoryLimiter({ max: 5, windowMs: 60_000 }),
  passwords: { hasher: new AuthArgon2idHasher() },
})

auth.providers.use(
  password({
    findIdentityByEmail: (email) => auth.identities.findByEmail(email),
    passwords: auth.passwords,
  }),
)

auth.providers.use(
  magicLink({
    channels: {
      email: new AuthResendChannel({
        apiKey: process.env.RESEND_API_KEY!,
        from: 'no-reply@example.com',
      }),
    },
    findIdentityByEmail: (email) => auth.identities.findByEmail(email),
    autoCreateIdentity: true,
    autoCreateProfile: (email) => ({ displayName: email.split('@')[0] }),
  }),
)

// In dev: this only warns. In production it throws on bad config.
auth.strict({ env: process.env.NODE_ENV === 'production' ? 'production' : 'development' })

Mount on Express

src/server.ts
import express from 'express'
import { AuthError } from '@gentleduck/auth/core'
import {
  authMountProviderBegin,
  authMountSession,
  authMountSignIn,
  authMountSignOut,
} from '@gentleduck/auth/server/express'
import { auth } from './lib/auth'

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

app.post('/auth/signin', authMountSignIn(auth))
app.post('/auth/signout', authMountSignOut(auth))
app.get('/auth/session', authMountSession(auth))
app.post('/auth/providers/:providerId/begin', (req, res, next) =>
  authMountProviderBegin(auth, req.params.providerId)(req, res, next),
)

app.get('/api/me', async (req, res, next) => {
  try {
    const ctx = await auth.resolveSession(req)
    if (!ctx) throw new AuthError('AUTH/UNAUTHENTICATED')
    res.json({ identity: ctx.identity })
  } catch (err) {
    next(err)
  }
})

app.use((err, req, res, next) => {
  if (err instanceof AuthError) {
    res.status(err.status).json(err.toJSON())
    return
  }
  next(err)
})

app.listen(3000)

Drop in the React client

src/App.tsx
import { AuthProvider, authUseSession, authUseSignIn, authUseSignOut } from '@gentleduck/auth/client/react'

export function App() {
  return (
    <AuthProvider baseUrl="/auth">
      <Page />
    </AuthProvider>
  )
}

function Page() {
  const { data, status } = authUseSession()
  const signIn = authUseSignIn()
  const signOut = authUseSignOut()

  if (status === 'loading') return <p>Loading...</p>

  if (status === 'guest') {
    return (
      <form onSubmit={async (e) => {
        e.preventDefault()
        const fd = new FormData(e.currentTarget)
        await signIn.mutateAsync({
          providerId: 'password',
          input: { email: fd.get('email'), password: fd.get('password') },
        })
      }}>
        <input name="email" type="email" required />
        <input name="password" type="password" required />
        <button type="submit" disabled={signIn.isLoading}>Sign in</button>
        {signIn.error && <p style={{ color: 'red' }}>{signIn.error.code}</p>}
      </form>
    )
  }

  return (
    <div>
      <p>Hi, {data.identity.profile.displayName}</p>
      <button onClick={() => signOut.mutate()}>Sign out</button>
    </div>
  )
}

What you got

  • A real durable SQL store for identities, credentials, and sessions.
  • Argon2id hashing.
  • Password + magic-link sign-in.
  • Cookie-based session, HttpOnly, SameSite=Lax.
  • React client with authUseSession / authUseSignIn / authUseSignOut.
  • CSRF guard available via authCsrfGuard (wire it on mutating routes).

What's next