Service Startup

How the IAM service boots, initializes pools and databases, mounts middleware and routes, and serves requests, covering both standalone deployment and library integration.

The IAM service ships two entry points. The @riavzon/auth/service export provides a complete standalone server that reads an age-encrypted configuration file, creates the Express application, and begins listening for requests. The @riavzon/auth export provides the same building blocks as individual functions and routers so you can mount them into your own Express application.

Both paths converge on the same core function: bootstrapApp. This function takes a validated configuration object, creates every pool, table, middleware, and route the service needs, and returns a ready to use Express app.


Package Exports

The package exposes two subpath exports:

ExportEntryPurpose
@riavzon/authdist/main.mjsLibrary mode. Individual functions, middleware, routers, and utilities.
@riavzon/auth/servicedist/service.mjsStandalone mode. Exports bootstrapApp and startServer. Auto-starts when NODE_ENV !== 'test'.

When you import from @riavzon/auth, nothing boots automatically. When you import from @riavzon/auth/service, the module calls startServer() immediately unless NODE_ENV is test. In production, this is the module you point your container's CMD at.

For a complete list of every export, see the API reference.


startServer

The startServer function is the outermost entry point in standalone mode. It handles configuration loading, application bootstrap, config file cleanup, and HTTP listening.

Startup Flow

Locate the configuration file

The function reads the CONFIG_PATH environment variable. If the variable is not set, it defaults to /run/app/config.json. It then checks that the file exists using fs.access. If the file is missing, the process exits with a fatal error.

Parse configuration

The file is read as UTF-8 and parsed with JSON.parse. The resulting object is passed directly to bootstrapApp, which validates it against the Zod schema internally. See the Configuration reference for the full schema.

Bootstrap the application

Calls bootstrapApp(config), which initializes all pools, tables, middleware, and routes. Returns a fully configured Express app.

Delete the configuration file

After bootstrapping, the function deletes the configuration file from disk with fs.unlink. This ensures that sensitive credentials (database passwords, JWT secrets, HMAC keys, API keys) do not persist on the filesystem after they have been loaded into memory. If the SKIP_CONFIG_UNLINK environment variable is set to 'true', the file is kept.

Start listening

The app begins listening on the configured service.port (default 10000) and service.ipAddress (default 0.0.0.0). Once the server is ready, background data-refresh tasks are scheduled.

import { startServer } from '@riavzon/auth/service'

// In production, this runs automatically.
// In tests (NODE_ENV=test), you call it manually:
await startServer()
The config file is deleted after parsing. If the process crashes during bootstrap and the file has already been unlinked, you need to re-decrypt it before restarting. In a Docker deployment this happens automatically because decrypt.sh runs on every container start.

bootstrapApp

The core bootstrap function. It accepts a ConfigurationInput object, performs all initialization, and returns an Express Application. Both the standalone server and library consumers use this function.

import { bootstrapApp } from '@riavzon/auth/service'
import type { ConfigurationInput } from '@riavzon/auth'

const app = await bootstrapApp(config)

Bootstrap Sequence

The function executes the following steps in order:

Configure OAuth providers

If the configuration includes a providers array, each entry is instantiated as an OAuthProvider object. Providers defined with a schema field use that Zod schema for profile validation. Providers defined with fields or useStandardProfile use the built-in StandardProfileSchema. See OAuth for provider configuration details.

Initialize the configuration manager

Calls configuration(config), which passes the raw config through the Zod schema for validation, then runs three initialization tasks concurrently:

  1. Main MySQL pool - Creates a mysql2/promise connection pool from config.store.main. This pool handles all authentication queries (users, refresh tokens, MFA codes).
  2. Rate limiter pool - Creates a callback-based mysql2 connection pool from config.store.rate_limiters_pool.store. This separate pool is used exclusively by rate-limiter-flexible for counter storage.
  3. Disposable email database - Opens an LMDB read-only database containing known disposable email domains. Used by the signup flow to reject temporary email addresses.

Once all three tasks complete, the configuration is frozen with Object.freeze and stored as a module-level singleton. Any subsequent call to getConfiguration() returns this frozen object.

Create the Express application

Creates a new Express app and conditionally configures trust proxy. When service.proxy.trust is true, the app trusts the configured proxy IP (service.proxy.server or service.ipAddress) and the client-facing proxy IP (service.proxy.ipToTrust). This ensures req.ip reflects the real client IP rather than the proxy's address.

Register the health endpoint

Mounts GET /health before any middleware. The handler returns 200 OK as plain text. Because it is registered before HMAC authentication, it is accessible without credentials. The Docker health check (healthcheck.js) polls this endpoint every 30 seconds.

Configure the bot detector

Initializes the @riavzon/bot-detector module. If the configuration has botDetector.enableBotDetector: true with custom settings, those settings are used. Otherwise, a default configuration is applied. If bot detection is disabled (enableBotDetector: false), a passive-mode configuration is loaded that disables all active checks but still provides the ApiResponse middleware for visitor tracking.

Create database tables

Creates the bot-detector tables (via createTables) and the IAM tables (via makeTables). The IAM schema includes four tables: users, api_tokens, refresh_tokens, and mfa_codes. All table creation statements use CREATE TABLE IF NOT EXISTS, so repeated calls are safe. After creating api_tokens, makeTables() also ensures the three API-token indexes exist and ignores duplicate-key errors on repeated runs. See Database for the full schema.

Mount middleware stack

The middleware is registered in a specific order. Each layer depends on the layers before it.

Mount route groups

The public API verification router is registered first. After that, the authenticated route groups are mounted in order: authentication routes, token rotation routes, magic link routes, BFF access routes, and API token management routes. The standalone operational config endpoint is mounted after those routers.

Warm up the bot detector

Calls warmUp() from the bot-detector module to preload geo, ASN, and threat databases into memory. This runs after route registration so that the first incoming request does not pay the cold-start penalty.

Register terminal handlers

Mounts the notFoundHandler (returns 404 for unmatched paths) and finalUnHandledErrors (catches unhandled errors and returns 500). These are registered last so they only trigger when no route or middleware handled the request.


Middleware Stack

The middleware is mounted in a fixed order. Every request passes through each layer sequentially:

OrderMiddlewarePurposeReference
1httpLoggerStructured request logging via pino-http. Assigns a unique X-Request-Id header to every request. Redacts Authorization headers and session cookies from logs.Logging
2x-powered-by disabledRemoves the default Express X-Powered-By header to avoid fingerprinting the server technology.
3helmetSets security headers: X-Frame-Options: DENY, Content-Security-Policy: frame-ancestors 'none', Cross-Origin-Embedder-Policy, Referrer-Policy: origin.
4headersSets Cache-Control: no-cache, private, max-age=0, Pragma: no-cache, Expires: 0 on every response. Prevents browsers and proxies from caching sensitive authentication responses.
5validateIpValidates that req.ip is a valid IP address using Node.js net.isIP(). Returns 403 Forbidden if the IP is missing or invalid.
6hmacAuth (conditional)Only mounted when service.Hmac is configured. Verifies the X-Client-Id, X-Timestamp, X-Signature, and X-Request-ID headers against a shared secret. Rejects requests with missing headers, unknown client IDs, expired timestamps, replayed nonces, or invalid signatures. Bypasses the health endpoint on localhost.HMAC
7apiVerificationRoute()Mounts GET /api/public/verify before body and cookie parsing.API Tokens
8express.json()Parses JSON request bodies.
9cookieParser()Parses Cookie headers into req.cookies.Cookies
10ApiResponseBot-detector middleware that attaches visitor data and geo information to every request.Bot Detection
The middleware and route order is security-critical. IP validation runs before HMAC so that requests with spoofed or missing IPs are rejected before signature verification begins. The public API verification route is mounted before body parsing and cookie parsing because it does not need either. The ApiResponse middleware is last because it relies on cookies being parsed.

Route Groups

After the early health check and service-wide middleware, six route groups are mounted. Five are authenticated or semi-public routers, and the operational config endpoint is mounted afterward as a direct handler.

Public API Verification Route

Handles machine-to-machine API token verification.

MethodPathPurposeReference
GET/api/public/verifyVerify an API token against a required privilegeAPI Tokens

This route is mounted before express.json(), cookieParser(), and the bot-detector middleware. It reads the token from the x-api-key header and the required privilege from the privilege query parameter.

Authentication Routes

Handles account creation, login, and OAuth flows.

MethodPathPurposeMiddlewareReference
POST/signupCreate a new accountcontentType('application/json'), json({ limit: '1kb' }), canary cookie checkSignup
POST/loginAuthenticate with email and passwordcontentType('application/json'), json({ limit: '1kb' }), canary cookie checkLogin
POST/auth/OAuth/:providerNameAuthenticate via an OAuth providercontentType('application/json'), json({ limit: '4kb' }), canary cookie checkOAuth

All three routes enforce a Content-Type: application/json header via the contentType middleware. The json() middleware limits request bodies to 1 KB (4 KB for OAuth) and rejects empty bodies with a 403. Each route also requires the canary_id cookie from the bot-detector and returns 400 if it is missing.

Token Rotation Routes

Handles token refresh and logout.

MethodPathPurposeMiddlewareReference
POST/auth/user/refresh-sessionRotate both access and refresh tokensrequireRefreshToken, acceptCookieOnly, getFingerPrint, checkForActiveMfaRefresh Tokens
POST/auth/logoutRevoke the current sessionrequireRefreshToken, requireAccessToken, acceptCookieOnlyLogout

The acceptCookieOnly middleware enforces that these requests send only cookies: no JSON body, no query string, and no Content-Type header. This prevents CSRF attacks by ensuring the request cannot carry attacker-controlled data beyond the browser's automatic cookie attachment.

Handles MFA verification, password reset, custom MFA flows, and email changes.

MethodPathPurposeReference
GET/auth/verify-mfaVerify an email MFA link (redirects)MFA
POST/auth/verify-mfaSubmit an MFA codeMFA
POST/custom/mfa/:reasonInitiate a custom MFA flowMFA
GET/auth/verify-custom-mfaVerify a custom MFA linkMFA
POST/auth/verify-custom-mfaSubmit a custom MFA codeMFA
POST/update/emailChange the user's email addressMFA
POST/auth/forgot-passwordInitiate a password resetPassword Reset
GET/auth/reset-passwordVerify a password reset linkPassword Reset
POST/auth/reset-passwordSubmit a new passwordPassword Reset

Custom MFA and email update routes require a valid access token, refresh token, fingerprint, and a checkForActiveMfa pass before protectRoute runs full anomaly detection on these requests.

BFF Access Routes

Backend-for-Frontend routes used by your application server to verify user sessions.

MethodPathPurposeReference
GET/secret/dataVerify the user's session and return authorization dataBFF
GET/secret/accesstoken/metadataReturn decoded access token payload and TTLBFF

Both routes require an access token (Authorization: Bearer header), a refresh token (session cookie), and a valid fingerprint. The routes also call checkForActiveMfa before protectRoute performs full JWT verification and anomaly detection.

API Token Management Routes

Handles authenticated creation and management of API tokens.

MethodPathPurposeReference
POST/api/manage/new-tokenCreate a new API tokenCreating Tokens
GET/api/manage/list-metadataList the user's valid API tokensToken Listing
POST/api/manage/revokeRevoke an API tokenRevocation
POST/api/manage/metadataReturn metadata for one API tokenMetadata
POST/api/manage/rotateRotate an API tokenRotation
POST/api/manage/ip-restriction-updateUpdate a token IP whitelistIP Restriction
POST/api/manage/privilege-updateUpdate a token privilegePrivileges

All API token management routes require requireAccessToken, requireRefreshToken, getFingerPrint, checkForActiveMfa, and protectRoute. The POST routes also enforce contentType('application/json') and express.json({ limit: '1kb' }). The GET /api/manage/list-metadata branch skips the JSON guards because it does not accept a request body.

Operational Config Endpoint

A single endpoint mounted outside the route groups:

MethodPathPurpose
GET/operational/configReturn the cookie domain and access token TTL to a trusted client

This endpoint is restricted by physical IP address. It compares req.socket.remoteAddress against service.clientIp or service.proxy.ipToTrust. Only the configured trusted client can access it.


Terminal Handlers

Two handlers are registered after all routes:

HandlerStatusBody
notFoundHandler404{ "error": "The page you are looking for doesn't exists" }
finalUnHandledErrors500 (or res.statusCode if > 415){ "error": "<message>" }

The error handler logs the full error via pino before sending the response. It picks the status code from the current res.statusCode if it is above 415, otherwise defaults to 500.


Configuration

The configuration object is validated at startup by a strict Zod schema. Every field is type-checked at runtime. If validation fails, the process exits with a detailed error showing which fields failed and why.

The schema covers database connections, JWT settings, password hashing, bot detection, rate limiting, magic links, OAuth providers, email credentials, and standalone server options. Each section is documented in its own essentials page, and the complete schema reference is available in the Configuration reference.


Docker Deployment

The standalone service is designed to run as a Docker container with encrypted configuration.

Container Architecture

The Dockerfile uses a multi-stage build:

Go builder stage

Compiles the age encryption tool and mmdbctl from source. These binaries are copied into later stages.

Base stage

Starts from node:lts-slim. Installs minimal runtime dependencies (libatomic1, curl, ca-certificates). Creates a non-root user (appuser, UID 10001) for the production stage.

Builder stage

Installs npm dependencies with npm ci, runs bot-detector init to generate the shield-base data sources, and builds the TypeScript source with the configured build tool.

Production stage

Copies only the built artifacts, production node_modules, bot-detector data sources, and the decrypt.sh and healthcheck.js scripts. Drops all development tools, npm itself, and build caches. Runs as appuser (non-root) with no-new-privileges enforced.

Entrypoint Flow

The container entrypoint is decrypt.sh, which runs before the main process:

Check for existing config

If /run/app/config.json already exists (from a previous run or manual mount), the script skips decryption and proceeds directly to the main command.

Decrypt the configuration

Reads the age private key from /run/secrets/age_key and the encrypted config from /run/secrets/encrypted_config. Decrypts using age -d and writes the result to /run/app/config.json with 0400 permissions (read-only for the owner).

Launch the service

Calls exec "$@", which replaces the shell process with the Docker CMD: node dist/service.mjs. The service startServer function then reads the config from the default path (/run/app/config.json).

Health Check

Docker runs a health check every 30 seconds:

healthcheck.js
http.request({ host: '127.0.0.1', port: 10000, path: '/health', timeout: 2000 })

The check hits GET /health on localhost. A 2xx or 401 response means the service is healthy. The 401 case covers scenarios where HMAC is enabled but the health check runs without headers. Because the HMAC middleware skips localhost health checks, this typically returns 200.


Background Tasks

After the server starts listening, startServer schedules three recurring background tasks:

TaskCommandDefault IntervalPurpose
Bot detector refreshbot-detector refresh24 hoursUpdates IP reputation, geo, and threat intelligence data sources
Bot detector generatebot-detector generate3 daysRegenerates compiled MMDB databases from updated sources
Disposable email listshield-base --email --path=dist7 daysUpdates the LMDB database of known disposable email domains

Each task runs with nice -n 19 (lowest CPU priority) to minimize impact on request handling. Tasks are scheduled with setTimeout and reschedule themselves after completion, so the interval measures the gap between the end of one run and the start of the next.

These tasks keep the bot detector's threat intelligence and the signup flow's disposable email list current without requiring a service restart. The first run is deferred by the full interval after startup, so a freshly deployed service uses the data that was baked into the image at build time.

Library Integration

When using @riavzon/auth as a library in your own Express application, you skip startServer and either call bootstrapApp or mount the individual pieces yourself.

Using bootstrapApp

The simplest integration. Provides the full IAM service as a sub-application:

import express from 'express'
import { bootstrapApp } from '@riavzon/auth/service'

const mainApp = express()

const authApp = await bootstrapApp({
  store: { main: { host: 'localhost', ... }, rate_limiters_pool: { store: { host: 'localhost', ... }, dbName: 'rate_limiters' } },
  password: { pepper: 'your-pepper' },
  botDetector: { enableBotDetector: false },
  htmlSanitizer: {},
  magic_links: { jwt_secret_key: '...', domain: 'https://example.com', notificationEmail: { ... } },
  jwt: { jwt_secret_key: '...', access_tokens: {}, refresh_tokens: { refresh_ttl: 604800000, domain: 'example.com', MAX_SESSION_LIFE: 2592000000, maxAllowedSessionsPerUser: 5, byPassAnomaliesFor: 86400000 } },
  email: { resend_key: '...', email: '[email protected]' },
})

mainApp.use('/auth', authApp)
mainApp.listen(3000)
When mounting the auth app under a prefix like /auth, remember that the internal routes already include their own prefixes (e.g., /auth/user/refresh-session). Adjust your mount point to avoid doubled path segments.

Using Individual Routers

For more control, call configuration() to initialize the pools and config, then mount only the routers you need:

import express from 'express'
import cookieParser from 'cookie-parser'
import {
  configuration,
  authenticationRoutes,
  tokenRotationRoutes,
  magicLinks,
  bffAccessRoute,
  apiVerificationRoute,
  apiProtectedRoutes,
} from '@riavzon/auth'

const app = express()

await configuration({
  store: { ... },
  password: { pepper: '...' },
  // ... rest of config
})

// Mount only what you need
app.use(apiVerificationRoute())
app.use(cookieParser())
app.use(authenticationRoutes)
app.use(tokenRotationRoutes)
app.use(magicLinks)
app.use(bffAccessRoute)
app.use(apiProtectedRoutes())

app.listen(3000)
You must call configuration() before mounting any routers. The routers and controllers call getConfiguration() internally, which throws if the config has not been initialized. Register cookieParser() before any router that reads req.cookies. The same applies to getPool(), poolForLibrary(), and getDisposableEmailList().

CLI Initialization

The package also exposes a CLI binary (auth) and an initAuthData function for one-time setup tasks like creating database tables and downloading the disposable email list:

import { initAuthData } from '@riavzon/auth'

await initAuthData(config)

Or from the command line:

npx auth ./config.json

This runs configuration(), bot-detector setup, makeTables(), createTables(), and the disposable email LMDB download in sequence.


Complete Route Map

For reference, here is every route the IAM service registers:

MethodPathAuth RequiredBodyReference
GET/healthNoNone
POST/signupcanary cookieJSON (1 KB)Signup
POST/logincanary cookieJSON (1 KB)Login
POST/auth/OAuth/:providerNamecanary cookieJSON (4 KB)OAuth
POST/auth/user/refresh-sessionRefresh cookieNone (cookie only)Refresh Tokens
POST/auth/logoutAccess + RefreshNone (cookie only)Logout
GET/auth/verify-mfaMagic link tokenNoneMFA
POST/auth/verify-mfaMagic link tokenJSON (1 KB)MFA
POST/custom/mfa/:reasonAccess + Refresh + JWTJSON (1 KB)MFA
GET/auth/verify-custom-mfaAccess + Refresh + JWTNoneMFA
POST/auth/verify-custom-mfaAccess + Refresh + JWTJSON (1 KB)MFA
POST/update/emailAccess + Refresh + JWTJSON (1 KB)MFA
POST/auth/forgot-passwordNoneJSON (1 KB)Password Reset
GET/auth/reset-passwordMagic link tokenNonePassword Reset
POST/auth/reset-passwordMagic link tokenJSON (1 KB)Password Reset
GET/secret/dataAccess + Refresh + JWTNoneBFF
GET/secret/accesstoken/metadataAccess + Refresh + JWTNoneBFF
GET/operational/configTrusted IP onlyNone

Summary

ConceptDescription
Two entry points@riavzon/auth for library usage, @riavzon/auth/service for standalone deployment
startServerReads encrypted config, calls bootstrapApp, deletes config from disk, listens on port
bootstrapAppInitializes pools, tables, middleware, routes. Returns Express app
Config lifecycleDecrypted at container start, parsed at boot, deleted from disk, frozen in memory
Middleware orderLogger, helmet, cache headers, IP validation, HMAC (optional), JSON parser, cookie parser, bot detector
Route groupsAuthentication, token rotation, magic links, BFF access, operational config
Background tasksBot detector data refresh (daily), database generation (3 days), disposable email update (weekly)
Docker securityRead-only filesystem, no capabilities, non-root user, tmpfs for secrets, PID limits
Health checkGET /health returns 200 OK, polled every 30 seconds by Docker
Logo