For AI agents: the complete documentation index is available at https://dotpress.dev/llms.txt, the full documentation bundle is available at https://dotpress.dev/llms-full.txt, and this page is available as Markdown at https://dotpress.dev/examples/auth.md.

Authentication

A private notes API with @dotpress/auth, on Express:

  • sign-up, login, and the access token in the Authorization header, for mobile apps and other services;
  • cookie sessions for browsers: both tokens in httpOnly cookies, protected against cross-site request forgery;
  • refresh token rotation, with the session revoked when a used refresh token comes back;
  • logout of the current device or of every device, and a password change that logs out every device;
  • routes for authenticated users (requireAuth), and for admins (requireRole('admin'));
  • the OpenAPI document and the Scalar page, with the guarded routes marked as secured.

Source: examples/auth.

Run it

npx degit dotpressjs/dotpress/examples/auth my-api
cd my-api
npm install
ADMIN_PASSWORD='choose a long passphrase' npm run dev

Without JWT_PRIVATE_KEY and JWT_PUBLIC_KEY, a temporary key pair is generated at startup: tokens stop working at each restart. ADMIN_PASSWORD creates an admin account.

curl -X POST localhost:3000/users -H 'content-type: application/json' \
  -d '{"username":"alice","password":"correct horse battery staple"}'
curl -X POST localhost:3000/auth/login -H 'content-type: application/json' \
  -d '{"username":"alice","password":"correct horse battery staple"}'
# { "accessToken": "…", "refreshToken": "…", "expiresIn": 900, … }
curl localhost:3000/notes -H 'authorization: Bearer <accessToken>'

http://localhost:3000/docs lists every route: log in there, then paste the access token in the Authorize dialog.

The service

createAuth() with the keys, the issuer and the data provider. The type of ctx.user is declared here, once:

import { createAuth, type KeyPairSettings } from '@dotpress/auth'
import type { Store } from './store.ts'

// Type of ctx.user: what dataProvider.findUserById() returns.
declare module '@dotpress/auth' {
  interface AuthUser {
    id: string
    username: string
    roles: string[]
  }
}

export interface AuthOptions {
  keys: KeyPairSettings
  store: Store
  /** `iss` claim of the tokens: the URL of the API, per environment. */
  issuer: string
  /** bcrypt cost. Defaults to 12 (about 200 ms per login). */
  passwordHashRounds?: number
}

export const createAuthService = ({
  keys,
  store,
  issuer,
  passwordHashRounds,
}: AuthOptions) =>
  createAuth({
    keys,
    issuer,
    dataProvider: store.dataProvider,
    ...(passwordHashRounds !== undefined && { passwordHashRounds }),
    // Clients choose at login: { mode: 'cookie' } for browsers (both tokens
    // in httpOnly cookies, out of reach of the JavaScript of the page), the
    // body otherwise (mobile apps, other services).
    cookies: { enabled: true, accessTokenInCookie: true },
  })

The data provider (src/store.ts) keeps the users and the refresh tokens in memory: replace it with your database, as in the SQL data provider of the Tasks API.

The app

import { authPlugin } from '@dotpress/auth'
import { createApp, type HttpAdapter, type Logger } from '@dotpress/core'
import { openapiPlugin } from '@dotpress/openapi'
import { createAuthService, type AuthOptions } from './auth.ts'
import { createNoteStore, type NoteStore } from './notes.ts'
import { adminRouter, notesRouter, usersRouter } from './routes.ts'

export interface NotesAppOptions<
  TAdapter extends HttpAdapter,
> extends AuthOptions {
  adapter: TAdapter
  notes?: NoteStore
  logger?: Logger
}

/**
 * Creates the app on any adapter: Express in `server.ts`, the memory
 * adapter of `@dotpress/testing` in the tests.
 */
export const createNotesApp = <TAdapter extends HttpAdapter>({
  adapter,
  notes = createNoteStore(),
  logger,
  ...authOptions
}: NotesAppOptions<TAdapter>) => {
  const auth = createAuthService(authOptions)

  return createApp({
    adapter,
    ...(logger && { logger }),
    routers: [
      usersRouter(auth, authOptions.store),
      notesRouter(notes),
      adminRouter(authOptions.store),
    ],
    plugins: [
      // Reads the access token of every request (ctx.user, ctx.auth), and
      // serves /auth/login, /auth/refresh, /auth/me, /auth/restore and
      // /auth/logout.
      authPlugin(auth),
      // The routes guarded by requireAuth and requireRole() are documented
      // as secured: Scalar shows an Authorize button.
      openapiPlugin({
        info: { title: 'Notes API', version: '1.0.0' },
        tags: [
          { name: 'auth', description: 'Sessions' },
          { name: 'users', description: 'Accounts' },
          { name: 'notes', description: 'Private notes' },
          { name: 'admin', description: 'Administration' },
        ],
      }),
    ],
  })
}

The routes

Sign-up validates the password length for bcrypt, a password change revokes every session, and each user only sees their own notes:

import {
  conflictError,
  createRouter,
  empty,
  forbiddenError,
  notFoundError,
} from '@dotpress/core'
import {
  isPasswordTooLong,
  requireAuth,
  requireRole,
  type Auth,
} from '@dotpress/auth'
import { z } from 'zod'
import type { Note as StoredNote, NoteStore } from './notes.ts'
import type { Store, User } from './store.ts'

// bcrypt reads 72 bytes at most: longer passwords get a validation error
// instead of being truncated.
const Password = z
  .string()
  .min(12)
  .refine((password) => !isPasswordTooLong(password), 'Password too long')

const PublicUser = z
  .object({
    id: z.uuid(),
    username: z.string(),
    role: z.enum(['user', 'admin']),
    createdAt: z.iso.datetime(),
  })
  .meta({ id: 'User' })

const Note = z
  .object({ id: z.uuid(), text: z.string(), createdAt: z.iso.datetime() })
  .meta({ id: 'Note' })

/** Never returns the password hash. */
const toPublicUser = ({ id, username, role, createdAt }: User) => ({
  id,
  username,
  role,
  createdAt,
})

/** The owner is the authenticated user: not returned. */
const toPublicNote = ({ id, text, createdAt }: StoredNote) => ({
  id,
  text,
  createdAt,
})

const USERNAME_TAKEN = 'This username is already taken'
const NOTE_NOT_FOUND = 'Note not found'

/** Sign-up and password change. */
export const usersRouter = (auth: Auth, store: Store) => {
  const router = createRouter()

  router.defineRoute({
    method: 'post',
    path: '/users',
    status: 201,
    schema: (z) => ({
      body: z.object({
        username: z.string().trim().min(3).max(50),
        password: Password,
      }),
      response: PublicUser,
    }),
    meta: {
      summary: 'Sign up',
      tags: ['users'],
      responses: { 409: USERNAME_TAKEN },
    },
    handler: async ({ req }) => {
      if (await store.users.findByUsername(req.body.username)) {
        return conflictError(USERNAME_TAKEN)
      }
      const user = await store.users.create({
        username: req.body.username,
        passwordHash: await auth.hashPassword(req.body.password),
      })
      return toPublicUser(user)
    },
  })

  router.defineRoute({
    method: 'put',
    path: '/users/me/password',
    status: 204,
    // ctx.user is defined and typed in the handler.
    middlewares: [requireAuth],
    schema: (z) => ({
      body: z.object({ currentPassword: z.string(), newPassword: Password }),
    }),
    meta: {
      summary: 'Change the password, and log out every device',
      tags: ['users'],
      responses: { 403: 'Invalid current password' },
    },
    handler: async ({ req, user }) => {
      const stored = await store.users.get(user.id)
      if (
        !(await auth.verifyPassword(
          req.body.currentPassword,
          stored?.passwordHash
        ))
      ) {
        return forbiddenError('Invalid current password')
      }
      await store.users.setPasswordHash(
        user.id,
        await auth.hashPassword(req.body.newPassword)
      )
      // A stolen session must not survive a password change.
      await auth.settings.dataProvider.revokeTokens(user.id)
      return empty()
    },
  })

  return router
}

/** Notes of the authenticated user: every route of the group requires it. */
export const notesRouter = (notes: NoteStore) => {
  const router = createRouter()
  const group = router.createGroup('/notes', [requireAuth])

  group.defineRoute({
    path: '/',
    schema: (z) => ({ response: z.array(Note) }),
    meta: { summary: 'List my notes', tags: ['notes'] },
    handler: async ({ user }) =>
      (await notes.listByOwner(user.id)).map(toPublicNote),
  })

  group.defineRoute({
    method: 'post',
    path: '/',
    status: 201,
    schema: (z) => ({
      body: z.object({ text: z.string().trim().min(1).max(1000) }),
      response: Note,
    }),
    meta: { summary: 'Create a note', tags: ['notes'] },
    handler: async ({ req, user }) =>
      toPublicNote(await notes.create(user.id, req.body.text)),
  })

  group.defineRoute({
    path: '/:id',
    schema: () => ({ response: Note }),
    meta: {
      summary: 'Get a note',
      tags: ['notes'],
      responses: { 404: NOTE_NOT_FOUND },
    },
    handler: async ({ req, user }) => {
      const note = await notes.get(req.params.id)
      // The notes of other users answer 404, not 403: their existence is
      // not revealed.
      if (!note || note.ownerId !== user.id) {
        return notFoundError(NOTE_NOT_FOUND)
      }
      return toPublicNote(note)
    },
  })

  return router
}

/** Administration: admins only (401 without user, 403 without the role). */
export const adminRouter = (store: Store) => {
  const router = createRouter()

  router.createGroup('/admin', [requireRole('admin')]).defineRoute({
    path: '/users',
    schema: (z) => ({ response: z.array(PublicUser) }),
    meta: { summary: 'List the users', tags: ['admin'] },
    handler: async () => (await store.users.list()).map(toPublicUser),
  })

  return router
}

Body or cookies

The client chooses at login:

Login bodyTokensNext requests
{ username, password }in the response bodyAuthorization: Bearer <accessToken>; POST /auth/refresh with both tokens in the body
{ username, password, mode: 'cookie' }in httpOnly cookies, never in the bodythe browser sends the cookies; POST /auth/restore when the page loads

In cookie mode, the API accepts the cookies from its own origin only. A front-end served from another origin must be allowed by the cors option of the app, with credentials: true.

Before production

  • Give the key pair in JWT_PRIVATE_KEY and JWT_PUBLIC_KEY, from the secrets of your platform, and the URL of the API in JWT_ISSUER.
  • Replace the in-memory stores with your database, and give the login throttle a shared store when you run several instances.
  • Go through the security checklist.

Learn more