> 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.

# 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`](https://github.com/dotpressjs/dotpress/tree/main/examples/auth).

## Run it

```sh
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.

```sh
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:

```ts file="<root>/../examples/auth/src/auth.ts"
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](https://dotpress.dev/auth/data-provider.md#an-sql-data-provider) of the Tasks API.

## The app

```ts file="<root>/../examples/auth/src/app.ts"
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:

```ts file="<root>/../examples/auth/src/routes.ts"
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 body                               | Tokens                                   | Next requests                                                                            |
| ---------------------------------------- | ---------------------------------------- | ---------------------------------------------------------------------------------------- |
| `{ username, password }`                 | in the response body                     | `Authorization: Bearer <accessToken>`; `POST /auth/refresh` with both tokens in the body |
| `{ username, password, mode: 'cookie' }` | in `httpOnly` cookies, never in the body | the 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](https://dotpress.dev/auth/passwords.md#a-shared-store) when you run several instances.
- Go through the [security checklist](https://dotpress.dev/auth/security.md).

## Learn more

- [Authentication](https://dotpress.dev/auth/index.md)
- [Sessions and tokens](https://dotpress.dev/auth/sessions.md)
- [Cookie mode and CSRF](https://dotpress.dev/auth/cookies.md)
- [Protecting routes](https://dotpress.dev/auth/protecting-routes.md)
