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/auth/index.md.

Authentication

@dotpress/auth authenticates the users of your API with a username and a password, and keeps them logged in with short-lived access tokens and rotating refresh tokens. It works with any database, through a small data provider, and on every adapter.

What it gives you:

  • the routes: login, token refresh, current user, cookie session restore, logout;
  • ctx.user and ctx.auth, typed, in every handler, and guards: requireAuth, requireRole(), requireClient();
  • sessions for browsers in httpOnly cookies, protected against cross-site request forgery;
  • security by default: refresh token rotation with reuse detection, login throttling, constant-time login failures, bcrypt hashing, RFC 9068 access tokens checked on every request.

How it works

  1. The client logs in with a username and a password. It gets an access token, valid 15 minutes, and a refresh token.
  2. It sends the access token with each request. The plugin verifies it, loads the user, and sets ctx.user.
  3. Before the access token expires, the client exchanges the refresh token for a new pair. The old refresh token can no longer be used: a stolen one is detected when it comes back.
  4. A logout revokes the session.

The access token is a JWT signed with your RSA key: verifying it costs no database lookup of the token. The refresh token is a random value; only its SHA-256 hash is stored, in your database.

Install

npm
yarn
pnpm
bun
deno
npm install @dotpress/auth@alpha
During the alpha

Install @dotpress/auth with the alpha tag: without it, npm installs @dotpress/auth 1.x, the plugin of Dotpress 1.

Set up

Describe your user, create the auth service, and install its plugin:

// The type of ctx.user: what dataProvider.findUserById() returns.
declare module '@dotpress/auth' {
  interface AuthUser {
    
AuthUser.id: string
id
: string
AuthUser.email: string
email
: string
AuthUser.roles: string[]
roles
: string[]
} } const
const auth: Auth
auth
=
function createAuth(input: AuthSettings): Auth

Creates the authentication service. It works without an app (scripts, workers), and is plugged into an app with authPlugin().

@exampleconst auth = createAuth({ keys, dataProvider }) await createApp({ adapter, plugins: [authPlugin(auth)] })
createAuth
({
AuthSettings.keys: KeyPairSettings
keys
: {
KeyPairSettings.secretKey: string

RSA private key, PEM encoded in PKCS#8 (BEGIN PRIVATE KEY).

secretKey
:
const env: {
    JWT_PRIVATE_KEY: string;
    JWT_PUBLIC_KEY: string;
}
env
.
type JWT_PRIVATE_KEY: string
JWT_PRIVATE_KEY
, // PKCS#8 PEM, "BEGIN PRIVATE KEY"
KeyPairSettings.publicKey: string

RSA public key, PEM encoded in SPKI (BEGIN PUBLIC KEY).

publicKey
:
const env: {
    JWT_PRIVATE_KEY: string;
    JWT_PUBLIC_KEY: string;
}
env
.
type JWT_PUBLIC_KEY: string
JWT_PUBLIC_KEY
, // SPKI PEM, "BEGIN PUBLIC KEY"
},
AuthSettings.issuer: string

iss claim of the access tokens, checked on every request: for example the URL of the API. Use a different value per environment and application, especially when they share a key pair.

issuer
: 'https://api.example.com', // required, one per environment
AuthSettings.dataProvider: DataProvider
dataProvider
, // your users and refresh tokens, see Data provider
})
defineRoute<"/profile", EmptyObject, false, readonly [AuthenticatedContext]>(route: RouteConfig<"/profile", EmptyObject, EmptyObject, false, readonly [AuthenticatedContext], unknown>): void

Declares a route on the global router.

defineRoute
({
RouteConfig<"/profile", EmptyObject, EmptyObject, false, readonly [AuthenticatedContext], unknown>.path: "/profile"
path
: '/profile',
RouteConfig<"/profile", EmptyObject, EmptyObject, false, readonly [AuthenticatedContext], unknown>.middlewares?: readonly [Middleware<AuthenticatedContext>] | undefined

Route middlewares, run after global and group middlewares. Context properties they guarantee (defineMiddleware()) are typed in the handler.

middlewares
: [
const requireAuth: Middleware<AuthenticatedContext>

Requires an authenticated user (401 UNAUTHORIZED otherwise). In the handlers of the routes using it, ctx.user and ctx.auth are defined.

requireAuth
],
RouteConfig<"/profile", EmptyObject, EmptyObject, false, readonly [AuthenticatedContext], unknown>.handler: RouteHandler<EmptyObject, {}, false, AuthenticatedContext>
handler
: ({
user: AuthUser

Authenticated user (authPlugin). Use requireAuth to require it.

user
}) => ({
email: string
email
:
user: AuthUser

Authenticated user (authPlugin). Use requireAuth to require it.

user
.
AuthUser.email: string
email
}),
}) const
const app: App<unknown>
app
= await
createApp<HttpAdapter<unknown>>(options: AppOptions<HttpAdapter<unknown>>): Promise<App<unknown>>

Creates a Dotpress application and mounts its routes on the adapter.

createApp
({
AppOptions<HttpAdapter<unknown>>.adapter: HttpAdapter<unknown>

HTTP adapter (Express, Hono, Fastify…).

adapter
,
AppOptions<TAdapter extends HttpAdapter = HttpAdapter<unknown>>.plugins?: Plugin[] | undefined

Plugins, set up in the given order (dependencies first). See definePlugin().

plugins
: [
function authPlugin(auth: Auth, options?: AuthPluginOptions): {
    readonly name: "auth";
    readonly auth: Auth;
    readonly securitySchemes: {
        readonly bearerAuth: {
            readonly type: "http";
            readonly scheme: "bearer";
            readonly bearerFormat: "JWT";
        };
    };
    readonly setup: (api: PluginAPI) => Promise<void>;
}

Plugs an authentication service into an app:

  • reads the access token of every request, and sets ctx.user and ctx.auth when it is valid;
  • registers the POST /auth/login, POST /auth/refresh, GET /auth/me, POST /auth/restore and POST /auth/logout routes.
authPlugin
(
const auth: Auth
auth
)] })

See Data provider to connect your database, and Keys and rotation to generate the keys.

The routes

RouteDescription
POST /auth/login{ username, password, clientId?, mode? }: logs in. mode: 'cookie' sets the tokens as cookies.
POST /auth/refreshExchanges a refresh token for a new pair.
GET /auth/meThe authenticated user.
POST /auth/restoreRestores a cookie session: the user and the expiration of the session.
POST /auth/logout{ refreshToken?, scope? }: revokes the current session, or every session with scope: 'all'.

Each route answers with userId, expiresIn and expiresAt (the expiration of the access token), so that the client knows when to refresh. The tokens are in the body, or in cookies (see below).

authPlugin(auth, { prefix: '/api/auth' }) changes the prefix; { routes: false } keeps only the authentication of the requests, for an API that only verifies tokens.

Browsers or other clients

The same API serves two kinds of clients:

ClientTokensConfiguration
Browser applicationBoth tokens in httpOnly cookies: the JavaScript of the page never sees themcookies: { enabled: true, accessTokenInCookie: true }, login with mode: 'cookie'
Mobile application, another service, a scriptBoth tokens in the body; the access token in the Authorization: Bearer headerthe default
Recommended for browsers

Use the cookie mode with accessTokenInCookie. A token stored in localStorage, or in any variable of the page, can be stolen by a single injected script (XSS); an httpOnly cookie cannot. See Cookie mode and CSRF.

Without an app

The auth service works in scripts and workers too:

const hash = await auth.hashPassword(password)
const { accessToken, refreshToken } = await auth.generateTokensForUser({ userId })
const session = await auth.verifyAccessToken(accessToken)

In this section

The Auth example puts it all together: sign-up, both modes, rotation, logout of one or every device, password change, roles.

Coming from v1

configurePlugin() is replaced by createAuth() and authPlugin(auth). Access tokens follow RFC 9068, issuer is required, logout revokes the current session only, login attempts are throttled, and tokens set as cookies are no longer returned in the body. See Migrate from v1.