Consenti

Backend — Advanced Configuration

createConsenti(config) accepts a single configuration object. Every field is optional — calling it with an empty object is valid and gives you a running server immediately. New here? Start with the Configuration quick start instead — this page is the complete reference, every field with its default value.

Full configuration reference

Every available option shown with its default value.

Full config example
ts
import { createConsenti } from '@consenti/api'

const consenti = createConsenti({
  // Optional but recommended
  storage: {
    driver: 'json',            // not recommended for production
    path: './consenti-data',   // always required, even for MySQL/Mongo/Postgres — profiles are stored here
    // uri: process.env.CONSENTI_DB_URI,   // postgresql | mysql | mongodb connection string
    // poolMax: 5,                         // postgresql | mysql | mongodb — max connections per process (default 5)
  },
  auth: {
    mode: 'local',
    adminEmail: process.env.CONSENTI_ADMIN_EMAIL ?? '[email protected]',
    adminPassword: process.env.CONSENTI_ADMIN_PASSWORD ?? 'Consenti@123',
    masterSecret: process.env.CONSENTI_ADMIN_MASTER_SECRET ?? 'random-jwt-secret',
  },

  // Optional
  basePath: '/consenti',       // URL prefix for all routes
  dashboard: true,             // serve admin SPA at basePath

  rateLimit: {
    windowMs: 60_000,          // 1-minute window
    maxRequests: 60,           // requests per window per IP
  },

  compliance: {
    type: 'auto',                 // 'auto' = geo-resolve per visitor | ComplianceGroupId = fixed group
    geoDataProvider: 'default',   // 'default' | 'hosted-geoip-lite' | 'geoip' | 'maxmind' | CountryResolverFn
    complianceMap: 'default',     // 'default' (embedded map) | URL | { version, override?, countries }
    gpc: true,                    // server-side GPC enforcement on consent writes; false = off

    tcf: {
      enabled: false,
      cmpId: 0,
      cmpVersion: 1,
      publisherCC: 'DE',          // ISO 3166-1 alpha-2 — needed for spec-correct TC strings
    },

    gpp: {
      enabled: false,
      cmpId: 0,
      cmpVersion: 1,
      mspaCoveredTransaction: false,   // required, no default
      mspaOptOutOptionMode: 0,         // 0 = not applicable | 1 = yes | 2 = no
      mspaServiceProviderMode: 0,
    },

    dataSigningHash: process.env.CONSENTI_DATA_SIGNING_HASH,  // signs consent records, ownership cookies, parental-consent tokens; salts hashed IPs. Auto-generated if omitted.

    parentalConsentTokenTtlDays: 7,   // expiry of parental-consent tokens (default 7)

    dataRetention: {
      purgeAfterDays: 365,       // delete consent records older than this
    },
  },

  // S3 sync for profile locale JSON files (optional)
  s3Api: {
    enabled: false,
    region: 'us-east-1',
    bucketName: 'consenti-profiles',
    accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
    secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,
    // sessionToken: process.env.AWS_SESSION_TOKEN,  // for temporary credentials
  },

  // CDN / cache invalidation hook (optional)
  handleCache: (paths, profileId, isPurge) => {
    // paths: locale file paths written or removed
    // profileId: id of the profile that triggered the write
    // isPurge: true = files removed (deactivate/delete); false = files written (create/activate)
  },

  multiTenant: {
    enabled: false,
  },

  maxBodySize: 1_048_576,      // 1 MB request body limit
  trustedProxies: [],          // IPs / CIDRs of reverse proxies (nginx, Cloudflare…)

  branding: {
    appName: 'My CMP',         // dashboard header, login page, browser tab
    appLogoPath: './logo.svg', // local file or https:// URL
    hidePoweredBy: false,      // true = hide "Powered by Consenti" badge
  },

  plugins: [],
})

storage

Controls which database backend Consenti uses. The driver field selects the engine; the remaining fields depend on which driver you choose.

storage.path is a directory — and always required

storage.path is a directory path, not a file path. Consenti creates the following layout inside it automatically:

text
${storage.path}/
  db/
    consenti-data.json     ← JSON adapter
    consenti.db            ← SQLite adapters
  profiles/
    ${tenantId}/
      ${profileId}/
        1/                 ← version 1 (immutable once written)
          en.json
          fr-FR.json
          default.json     ← copy of defaultLocale content
        2/                 ← version 2 on next edit
      ${complianceGroup}/ → symlink to the active profile's version dir (hot-serve path)
⚠️storage.path is required for every driver — including PostgreSQL, MySQL and MongoDB. The database holds consent records, users and templates, but profiles are always written to disk under ${storage.path}/profiles/ so the public routes can serve them with no database access. With a server driver db/ stays empty, but profiles/ is still used: keep the directory on persistent, writable storage, and if you run more than one instance, point them all at the same shared volume.
ℹ️Backward compat: if storage.path has a file extension (.json, .db), the parent directory is used and a deprecation warning is logged.
ℹ️${complianceGroup}/ is a directory symlink pointing at whichever version directory is currently active, not a copy — activating a profile repoints it atomically (one filesystem rename), so there's never a moment where it's empty or a mix of two versions. On Windows this is created as a directory junction (no admin rights or Developer Mode required), which requires an NTFS volume and works only when storage.path and its containing drive are on the same volume — both are true for any normal local install.

Default driver — json

When no storage config is provided, Consenti uses the built-in JSON file adapter. It requires zero installation — only node:fs and node:crypto, both Node.js built-ins. Data is kept in memory and written to disk atomically after every mutation (temp-file + rename, same guarantee as steno).

JSON driver
ts
storage: { driver: 'json', path: './consenti-data' } // path is a directory
⚠️The json driver loads the entire dataset into memory and is single-process only. It is designed for development, prototyping, and low-traffic single-instance deployments. Use a SQLite or server driver in production.

SQLite drivers

Three SQLite drivers are available. All create the same schema; choose based on your Node.js version and whether you can compile native addons.

DriverRequiresNotes
node:sqliteNode 22.5+Built-in, zero compilation. Recommended for Node 22.5+.
better-sqlite3 / sqliteNode 20+, native addonFastest option. Requires a prebuilt binary or build toolchain. sqlite is an alias.
node-sqlite3-wasmNode 20+WASM bundle, no compilation. Use when node:sqlite is unavailable and native addons are blocked.

node:sqlite is built into Node 22.5+ and requires no installation. The other two drivers are optional peer dependencies — install whichever one you need:

Install SQLite peer dep
bash
# Node 20+ native addon (fastest; needs build toolchain or prebuilt binary)
npm install better-sqlite3

# Node 20+ WASM fallback (no compilation required)
npm install node-sqlite3-wasm
SQLite config examples
ts
// Node 22.5+ — built-in, no install needed
storage: { driver: 'node:sqlite', path: './consenti-data' }

// Node 20+ with native addon
storage: { driver: 'better-sqlite3', path: './consenti-data' }

// Node 20+ WASM fallback
storage: { driver: 'node-sqlite3-wasm', path: './consenti-data' }

path is a directory: the database file ends up at ./consenti-data/db/consenti.db.

Server drivers

DriverConnection fieldNotes
postgresqluriPostgreSQL 13+. Pool size via poolMax.
mysqluriMySQL 8+ / MariaDB 10.6+. Pool size via poolMax.
mongodburiMongoDB 6+. Optional database override.

Each server driver is an optional peer dependency — install the one you need:

Install server driver peer dep
bash
npm install pg         # PostgreSQL
npm install mysql2     # MySQL / MariaDB
npm install mongodb    # MongoDB
Server driver config examples
ts
// PostgreSQL
storage: { driver: 'postgresql', path: './consenti-data', uri: process.env.CONSENTI_DB_URI }

// MySQL
storage: { driver: 'mysql', path: './consenti-data', uri: process.env.CONSENTI_DB_URI }

// MongoDB
storage: { driver: 'mongodb', path: './consenti-data', uri: process.env.CONSENTI_DB_URI, database: 'consenti' }
ℹ️path is in every example above on purpose — see storage.path is a directory.

Environment variables per driver

Every storage field can be set from the environment (code config wins over env vars). CONSENTI_DB_PATH is always set, whichever driver you use:

.env — SQLite
bash
CONSENTI_DB_DRIVER=node:sqlite
CONSENTI_DB_PATH=./consenti-data
.env — PostgreSQL
bash
CONSENTI_DB_DRIVER=postgresql
CONSENTI_DB_PATH=./consenti-data      # still required: profiles are stored here
CONSENTI_DB_URI=postgresql://consenti:your-password@localhost:5432/consenti

# …or individual fields instead of CONSENTI_DB_URI:
# CONSENTI_DB_HOST=localhost
# CONSENTI_DB_PORT=5432
# CONSENTI_DB_USER=consenti
# CONSENTI_DB_PASSWORD=your-password
# CONSENTI_DB_DATABASE=consenti
.env — MySQL / MariaDB
bash
CONSENTI_DB_DRIVER=mysql
CONSENTI_DB_PATH=./consenti-data      # still required: profiles are stored here
CONSENTI_DB_HOST=localhost
CONSENTI_DB_PORT=3306
CONSENTI_DB_USER=consenti
CONSENTI_DB_PASSWORD=your-password
CONSENTI_DB_DATABASE=consenti

# …or one connection string instead of the five lines above:
# CONSENTI_DB_URI=mysql://consenti:your-password@localhost:3306/consenti
.env — MongoDB
bash
CONSENTI_DB_DRIVER=mongodb
CONSENTI_DB_PATH=./consenti-data      # still required: profiles are stored here
CONSENTI_DB_URI=mongodb://consenti:your-password@localhost:27017/?authSource=admin
CONSENTI_DB_DATABASE=consenti         # default: consenti

When CONSENTI_DB_URI is set, the MySQL/PostgreSQL host, port, user, password and database fields are ignored.

storage field reference

KeyTypeDefaultDescription
driverstring'json' (when storage omitted)Required within the storage object. Selects the storage engine (see tables above).
pathstring'./consenti-data'Directory path — required for every driver, including PostgreSQL, MySQL and MongoDB (profiles are always stored here). Consenti creates db/, profiles/, and logs/ subdirectories inside; db/ holds the database file for json and SQLite only. Relative paths resolve from process.cwd(). Must not be inside the package installation directory.
uristring—Server drivers only (env: CONSENTI_DB_URI). Full connection string including credentials; takes precedence over host/port/user/password.
databasestringname in uriMongoDB only. Database name override.
host / portstring / number—Alternative to uri for server drivers.
user / passwordstring—Credentials when not embedded in uri.
databasestring—Database name for PostgreSQL / MySQL when not in uri.
poolMaxnumber5PostgreSQL, MySQL and MongoDB. Maximum connections this process opens. The default is deliberately low: shared hosts often cap connections per database user (5–25) across every app on the account. Raise it on a dedicated server; each app instance has its own pool.
statementTimeoutMsnumber—PostgreSQL / MySQL. Abort a query after this many milliseconds. Unset by default — a forced default could break a legitimate long-running export.
idleInTransactionTimeoutMsnumber—PostgreSQL only. Terminate a connection left idle inside an open transaction after this many milliseconds.

Table and collection names

With PostgreSQL, MySQL/MariaDB and MongoDB every table (collection) is named with a fixed consenti_ prefix — consenti_tenants, consenti_profiles, consenti_consent_records, consenti_users, consenti_schema_version, … (17 in all) — so Consenti can never collide with, or silently adopt, a table of the same name belonging to another app in the same database. The prefix is not configurable. SQLite and the JSON store keep plain names: their data lives in a file Consenti owns.

⚠️If the database still holds the old unprefixed tables from a version before 0.6.0 and no prefixed ones, Consenti refuses to start and tells you to run the migration script, rather than creating an empty set next to your data. See Upgrading from before 0.6.0.

Shared hosting and restricted database users

Consenti works against a database you can't administer — the cPanel / Plesk / managed-database case, where the database and its user are created in a control panel and the user can only touch that one database. It never runs CREATE DATABASE, USE, CREATE EXTENSION, GRANT, triggers or stored procedures: it connects to the database named in your config, creates its consenti_* tables on first start and upgrades them in place later.

DriverWhat the database user needs
MySQL / MariaDBSELECT, INSERT, UPDATE, DELETE, CREATE, ALTER, INDEX, REFERENCES on that database (ALL PRIVILEGES, the shared-host default, covers it)
PostgreSQLCREATE on the schema it connects to (normally public — the database owner has it) plus SELECT, INSERT, UPDATE, DELETE
MongoDBthe readWrite role on that database
  • Use a dedicated database. The prefix keeps Consenti's tables from clashing with other apps' tables, but one database per app is still the clean setup.
  • Set storage.path anyway — profiles are files. It must be a writable directory that survives restarts.
  • Connections. The pool defaults to 5 per process. If you see too many connections, lower storage.poolMax; on a dedicated server, raise it.
  • Names are used as given. cPanel prefixes names (cpuser_consenti, cpuser_app) — use the full string. In a connection string, URL-encode special characters in the password (@ → %40, / → %2F, # → %23), or use the separate host / user / password / database fields.
  • Versions. MySQL 8+ / MariaDB 10.6+ (the dashboard statistics use JSON_TABLE), PostgreSQL 13+, MongoDB 6+.
  • Several instances may start at once on an empty database: the first start is serialised with a named lock (MySQL/MariaDB) or advisory lock (PostgreSQL), neither of which needs a special privilege.
  • A first start that dies halfway is safe: PostgreSQL rolls back; MySQL/MariaDB cannot roll back DDL, so every statement is re-runnable and the version table is written last — fix the cause and start again. A missing privilege is named in the error.

Upgrading from before 0.6.0

PostgreSQL, MySQL/MariaDB and MongoDB only (SQLite and JSON: nothing to do). 0.6.0 renamed the tables to consenti_*. Rename your existing ones once, before starting the new version:

One-off rename
bash
# 1. Back up the database and stop the app.
# 2. Dry run — prints what would be renamed, changes nothing.
#    Uses the same CONSENTI_DB_* variables as the app
#    (or --driver, --uri, --host/--port/--user/--password, --database).
node node_modules/@consenti/api/scripts/migrate-to-consenti-prefix.mjs

# 3. Rename.
node node_modules/@consenti/api/scripts/migrate-to-consenti-prefix.mjs --apply

# 4. Start the app — it finishes any remaining schema upgrade itself.

The script only renames: no rows are read, copied or deleted, so it takes moments whatever the database size, and it is safe to re-run. MySQL/MariaDB renames every table in one atomic statement, PostgreSQL in one transaction, MongoDB collection by collection (each keeps its data and indexes). It refuses to overwrite a consenti_* table that already exists. It needs more than the app does: ALTER + DROP on the old tables and CREATE + INSERT on the new ones (MySQL; ALL PRIVILEGES covers it), table ownership (PostgreSQL) or the renameCollection action (MongoDB; readWrite includes it). Anything else that reads these tables directly — reports, BI tools — must switch to the new names.

auth

The auth object controls how the admin dashboard authenticates its users. Pick one mode and supply the fields that mode requires.

When auth is omitted entirely, Consenti defaults to mode: 'local' and reads credentials from environment variables:

Env varDefault fallbackPurpose
CONSENTI_ADMIN_EMAIL[email protected]Bootstrap super-admin email when no auth.adminEmail is set.
CONSENTI_ADMIN_PASSWORDConsenti@123Bootstrap super-admin password when no auth.adminPassword is set.
CONSENTI_ADMIN_MASTER_SECRETrandom per restartJWT signing secret. Takes precedence over auth.masterSecret.
⚠️A random CONSENTI_ADMIN_MASTER_SECRET means all sessions are invalidated on restart. Always set a stable secret in production.

Shared options (all modes)

KeyTypeDefaultDescription
mode'local' | 'jwt' | 'oidc' | 'saml' | 'custom''local'Authentication strategy for the admin dashboard.
masterSecretstringrandom on startHMAC-SHA256 secret used to sign session JWTs. Omit in dev; set a stable value in production or sessions expire on restart.

mode: 'local'

Built-in email + password login. On first boot, Consenti creates a super-admin account using adminEmail / adminPassword. The password is hashed with scrypt (node:crypto) and never stored in plain text. Subsequent logins issue a signed JWT stored in an HttpOnly cookie.

Brute-force protection is built in: 5 failed attempts within 15 minutes lock the account for the remainder of that window.

local auth
ts
auth: {
  mode: 'local',
  adminEmail: '[email protected]',
  adminPassword: process.env.CONSENTI_ADMIN_PASSWORD!, // min 12 chars; 16+ recommended
  masterSecret: process.env.CONSENTI_ADMIN_MASTER_SECRET!,         // stable secret for production
}
KeyRequiredDefaultDescription
adminEmailyesenv.CONSENTI_ADMIN_EMAIL → '[email protected]'Email address for the bootstrapped super-admin user.
adminPasswordyesenv.CONSENTI_ADMIN_PASSWORD → 'Consenti@123'Password for the bootstrapped super-admin user. Must be ≥ 12 characters; ≥ 16 recommended. Hashed with scrypt on first boot; ignored on subsequent starts.
ℹ️The bootstrap user is only created when the database has zero admin users. Adding more users via the dashboard after first boot does not require restarting the server.

mode: 'jwt'

Validate JWTs issued by an external system (e.g. your own auth service). Consenti verifies the token signature with masterSecret and extracts sub, email, roles, and tenantIdfrom the payload.

jwt auth
ts
auth: {
  mode: 'jwt',
  masterSecret: process.env.CONSENTI_ADMIN_MASTER_SECRET!, // must match the secret used to sign your tokens
}

Your auth service must issue tokens with at least these claims:

expected JWT payload
json
{
  "sub": "user-id",
  "email": "[email protected]",
  "roles": ["super_admin"],
  "tenantId": "default"
}
⚠️Only HMAC-SHA256 (alg: "HS256") tokens are supported in this mode. Tokens signed with RS256 / ES256 require mode: 'oidc'.

mode: 'oidc'

OpenID Connect Authorization Code flow with PKCE. Consenti auto-discovers endpoints from the issuer's /.well-known/openid-configuration and verifies the ID token signature using the provider's JWKS. Supports RS256, RS384, RS512, ES256, ES384, and ES512.

oidc auth
ts
auth: {
  mode: 'oidc',
  masterSecret: process.env.CONSENTI_ADMIN_MASTER_SECRET!, // signs the session cookie after OIDC login
  oidc: {
    issuer: 'https://accounts.google.com',  // or any OIDC-compliant provider
    clientId: process.env.OIDC_CLIENT_ID!,
    clientSecret: process.env.OIDC_CLIENT_SECRET!,
    redirectUri: 'https://yourapp.com/consenti/auth/oidc/callback',
    claimsMapping: {
      email: 'email',           // default; override if your IdP uses a different claim
      roles: 'consenti_roles',  // default; the claim that carries Consenti role names
    },
  },
}
KeyRequiredDefaultDescription
oidc.issueryes—OIDC provider base URL. Must expose /.well-known/openid-configuration.
oidc.clientIdyes—OAuth 2.0 client ID registered with the provider.
oidc.clientSecretyes—OAuth 2.0 client secret.
oidc.redirectUriyes—Callback URL registered with the provider. Must be: {basePath}/auth/oidc/callback.
oidc.claimsMapping.emailno"email"JWT claim that holds the user's email.
oidc.claimsMapping.rolesno"consenti_roles"JWT claim that holds an array of Consenti role names.
ℹ️The PKCE state / verifier pairs are held in memory. For multi-instance deployments, replace the in-memory store with a shared cache (Redis, etc.) by extending the auth handler.

mode: 'saml'

SAML 2.0 SP-initiated SSO. Consenti acts as the Service Provider; your Identity Provider handles authentication. Assertion validation is delegated to the samlify peer dependency, which you must install separately.

install peer dependency
bash
npm install samlify
saml auth
ts
auth: {
  mode: 'saml',
  masterSecret: process.env.CONSENTI_ADMIN_MASTER_SECRET!,
  saml: {
    issuer: 'https://idp.example.com',
    entryPoint: 'https://idp.example.com/sso/saml',  // IdP SSO URL
    cert: process.env.IDP_CERT!,                      // IdP signing certificate (PEM, without headers)
    callbackUrl: 'https://yourapp.com/consenti/admin/v1/auth/saml/acs',
  },
}
KeyRequiredDescription
saml.issueryesIdP entity ID / issuer string.
saml.entryPointyesIdP SSO endpoint URL (HTTP-Redirect binding).
saml.certyesIdP X.509 signing certificate in PEM format (without the -----BEGIN CERTIFICATE----- headers).
saml.callbackUrlyesYour ACS (Assertion Consumer Service) URL. Must be: {basePath}/auth/saml/callback.

Consenti exposes an SP metadata endpoint at {basePath}/auth/saml/metadata. Register this URL with your IdP or download the XML to configure the SP manually.

The SAML assertion must contain a NameID in email format and an optional roles attribute holding an array of Consenti role names.

mode: 'custom'

Bring your own authentication logic. Supply a validateUser async function that receives the raw Request object and returns an AdminUser(or null if the request is unauthenticated). Consenti calls this function on every protected admin route.

custom auth
ts
import type { AdminUser } from '@consenti/api'

auth: {
  mode: 'custom',
  validateUser: async (req: Request): Promise<AdminUser | null> => {
    const token = req.headers.get('authorization')?.replace('Bearer ', '')
    if (!token) return null

    // call your own auth service, validate a session cookie, etc.
    const user = await myAuthService.verifyToken(token)
    if (!user) return null

    return {
      id: user.id,
      tenantId: 'default',
      name: user.name,
      email: user.email,
      passwordHash: '',
      isActive: true,
      createdAt: new Date().toISOString(),
      updatedAt: new Date().toISOString(),
    }
  },
}
ℹ️In custom mode you own the entire authentication step. Consenti still enforces RBAC on the returned user's roles — the custom handler only decideswho the user is, not what they are allowed to do.

TOTP / Two-factor authentication

TOTP (RFC 6238) is available as an optional, per-user second factor, layered on top of whichever auth.mode is active. It is implemented entirely with node:crypto — no external dependency. Each admin user opts in individually via three admin routes:

TOTP setup (admin REST API)
ts
// 1. Generate a secret + QR code for the current user (requires a valid JWT)
POST {basePath}/admin/v1/auth/totp/setup
→ { qrUrl, message }

// 2. Scan the QR code in an authenticator app, then verify a submitted 6-digit code
POST {basePath}/admin/v1/auth/totp/verify
{ "token": "123456" }
→ { success: true, message: 'TOTP enabled successfully' }

// 3. Disable TOTP for the current user
POST {basePath}/admin/v1/auth/totp/disable
→ { success: true, message: 'TOTP disabled' }
ℹ️totpEnabled is reported on GET {basePath}/admin/v1/auth/me, but is not currently enforced as a required second factor on POST /auth/login itself — treat it as available account-hardening today, not a login gate.

basePath

URL prefix for all Consenti routes. Defaults to /consenti.

KeyTypeDefaultDescription
basePathstring'/consenti'All public API, admin API, and dashboard routes are mounted under this prefix.
basePath examples
ts
// Default — routes at /consenti/api/v1/...
basePath: '/consenti'

// Custom prefix
basePath: '/cmp'
// → public API:  /cmp/api/v1/consent
// → admin API:   /cmp/admin/...
// → dashboard:   /cmp  (SPA)
// → OpenAPI UI:  /cmp/api/docs

dashboard

Controls whether Consenti serves the built-in Preact admin SPA. Accepts a boolean or a config object.

ValueEffect
trueServe the dashboard at {basePath}.
false / omittedDashboard files are not served; admin API routes remain active.
{ enabled: true, path: '/admin' }Serve at a custom sub-path instead of basePath.
dashboard examples
ts
// Simplest — serve at /consenti
dashboard: true

// Disable (headless / API-only mode)
dashboard: false

// Serve at a custom path
dashboard: { enabled: true, path: '/admin' }
ℹ️The dashboard SPA is a static bundle bundled into the package. No separate build step is needed — it is served directly from the @consenti/api package directory.

rateLimit

In-memory sliding-window rate limiter applied to public API routes (consent reads / writes). Admin routes use a separate, fixed-window limiter (10 requests / 15 min) that cannot be configured.

KeyTypeDefaultDescription
enabledbooleantrueSet to false to disable rate limiting entirely (e.g. behind your own proxy that already rate-limits).
windowMsnumber60000Rolling window duration in milliseconds.
maxRequestsnumber60Maximum requests per IP per window. Requests over the limit receive 429 Too Many Requests.
rateLimit examples
ts
// Tighter limit for high-traffic sites
rateLimit: {
  windowMs: 60_000,    // 1-minute window
  maxRequests: 30,     // 30 req/min per IP
}

// Disable entirely (you handle rate limiting upstream)
rateLimit: { enabled: false }
ℹ️Rate-limit counters live in process memory. For multi-instance deployments, disable the built-in limiter and implement a shared counter (Redis, etc.) at your load balancer or API gateway layer.

compliance

Controls which cookie Compliance Group is applied to visitors. The recommended approach is automatic geo-routing (type: 'auto') — Consenti resolves the visitor's jurisdiction from geo signals and selects the correct compliance group.

Compliance groups

All behaviour (opt-in vs opt-out, GPC handling, LI validity) is determined by the visitor's compliance group. Eight groups are built in:

Group IDModelKey regulations
opt-inOpt-in (GDPR)GDPR, UK-GDPR, nFADP, KVKK, PDPA-TH, Middle East laws
opt-outOpt-out (US state laws)CCPA, VCDPA, CPA-CO, CTDPA, UCPA, 15 other US state laws
opt-out-strictOpt-out strict (CPRA)CPRA / California — GPC mandatory, sale/sharing categories
opt-in-dpdpaOpt-in (India)DPDPA — no Legitimate Interest, data fiduciary disclosure
opt-in-chinaOpt-in (China)PIPL, DSL, CSL — strict opt-in, no LI
opt-in-brazilOpt-in (Brazil)LGPD — opt-in with LI allowed under impact assessment
general-privacy-consentGeneral consentPIPEDA, POPIA, APPI, PDPA-SG/MY, 40+ others
notice-onlyNotice onlyJurisdictions with notice requirements but no consent mandate

compliance field reference

KeyTypeDefaultDescription
type'auto' | ComplianceGroupId'auto''auto' geo-resolves per visitor; a fixed ComplianceGroupId applies one group to every visitor globally.
geoDataProvider'default' | 'hosted-geoip-lite' | 'geoip' | 'maxmind' | CountryResolverFn'default'Source used to resolve the visitor's country. 'default' — timezone + Accept-Language heuristic, zero deps, least accurate. 'hosted-geoip-lite' — calls ipinfo.io via node:https, no install, requires outbound internet. 'geoip' — local lookup via optional geoip-lite peer dep. 'maxmind' — official MaxMind SDK, most accurate, requires .mmdb file. Pass a CountryResolverFn to use any custom source. It receives { ip, timezone, language, languages, locale } — the same hints the widget sends to /resolve-profile (language is the browser's primary tag, languages its full preference list, locale the locale the widget requested) — and must return { country: string | null, region: string | null, locale: string | null }, plus an optional complianceGroup?: string — when set, that group is used directly and the country/region jurisdiction-map lookup is skipped entirely (for a provider that already carries legal-grade jurisdiction data and needs to route into an operator-defined custom group).
complianceMap'default' | string | ComplianceMapData'default'The country → compliance-group map used by type: 'auto'. 'default' is the embedded map; a URL string is fetched and refreshed in the background; an object is used as given. By default a supplied map replaces the embedded one; override: false inside it merges your countries over the embedded map instead. Invalid data logs an error and falls back to 'default'. Details and examples below.
gpcboolean | 'strict'enabledServer-side Global Privacy Control enforcement on POST/PUT /consent. When a write arrives with gpcDetected: true, the server denies, regardless of what the client sent: under CPRA, cookies tagged cpraCategory: 'sale' or 'sharing'; under every other regulation, cookies with listenGpc: true. Skipped for DPDPA, which recognises no GPC opt-out signal. false turns the check off entirely (the widget's own client-side handling is unaffected); true, 'strict' or omitted leave it on — 'strict' is accepted but currently behaves like true. Whether the visitor-facing banner honours GPC is the per-profile gpcMode, a separate setting.
tcfTcfConfig— (omit to disable)IAB TCF integration: enabled, cmpId, cmpVersion, publisherCC. See tcf.
gppGppConfig— (omit to disable)IAB GPP (US National section): enabled, cmpId, cmpVersion, mspaCoveredTransaction, mspaOptOutOptionMode, mspaServiceProviderMode. See gpp.
dataSigningHashstringauto-generated in memorySecret that signs consent records, ownership cookies and parental-consent tokens and salts hashed IPs. Set it for production. See compliance.dataSigningHash.
parentalConsentTokenTtlDaysnumber7How long a parental-consent request/resolve token stays valid, in days. See compliance.dataSigningHash.
dataRetention{ purgeAfterDays: number }— (omit to keep records)Nightly hard-delete of consent records older than purgeAfterDays. Audit logs are never purged. See dataRetention.
ℹ️Two different GPC settings exist, at two levels. compliance.gpc (above) is a server-wide safety net applied to every consent write. gpcMode (ignore / honor / strict) is per profile, set in the dashboard profile wizard, and controls what the banner does when it detects GPC; the compliance group provides its default.
compliance examples
ts
// Zero-dep geo routing (timezone + Accept-Language heuristic)
compliance: {
  type: 'auto',
  geoDataProvider: 'default',
}

// IP-based — no install, calls ipinfo.io (requires outbound internet)
compliance: {
  type: 'auto',
  geoDataProvider: 'hosted-geoip-lite',
}

// IP-based — local lookup via geoip-lite (npm install geoip-lite)
compliance: {
  type: 'auto',
  geoDataProvider: 'geoip',
}

// Fixed group — GDPR-only product serving only EU visitors
compliance: {
  type: 'opt-in',
}

// Custom geo resolver — integrate your own GeoIP SaaS
compliance: {
  type: 'auto',
  // ctx: { ip, timezone, language, languages, locale } — languages/locale come from the widget
  geoDataProvider: async ({ ip, timezone, language, languages, locale }) => {
    const res = await fetch(`https://api.mygeoip.com/${ip}`)
    const data = await res.json()
    return { country: data.country_code, region: data.region, locale: data.locale ?? null }
  },
}

// Override the country -> compliance-group map itself
compliance: {
  type: 'auto',
  complianceMap: 'default', // 'default' (embedded map) | a URL string | an inline object
  // complianceMap: 'https://your-host.com/compliance-map.json',
  // Replaces the embedded map (default) — only the listed countries resolve:
  // complianceMap: { version: '2026-01', countries: { DE: { complianceGroup: 'opt-in' } } },
  // Merge over the embedded map — only DE changes, every other country keeps its built-in group:
  // complianceMap: { version: '2026-01', override: false, countries: { DE: { complianceGroup: 'opt-out' } } },
}

// Custom geo resolver that already knows the compliance group — skips the
// country/region jurisdiction-map lookup entirely for a provider carrying its own
// legal-grade jurisdiction data (e.g. routing into a custom, non-built-in group)
compliance: {
  type: 'auto',
  geoDataProvider: async ({ ip, timezone, language }) => {
    const res = await fetch(`https://api.my-legal-geo-service.com/${ip}`)
    const data = await res.json()
    return {
      country: data.country_code,
      region: data.region,
      locale: data.locale ?? null,
      complianceGroup: data.consentModel, // e.g. an operator-defined custom group id
    }
  },
}
ℹ️The embedded compliance map covers 190+ countries and territories with 8 compliance groups. compliance.complianceMap lets you override it: 'default' (the embedded map, unchanged), a URL string (fetched and used as the map — refreshed in the background per the response's Cache-Control/Expires header, 24h fallback if absent), or an inline object matching ComplianceMapData ({ version, override?, countries: { [code]: { complianceGroup, default?, description?, overriddenRegions? } } } — default falls back to complianceGroup when omitted). By default the supplied map replaces the embedded one, so only the countries you list resolve (override: true, the default). Set override: false in the document to merge your countries over the embedded map instead: a listed country replaces its embedded entry wholesale (including its overriddenRegions — nothing is deep-merged), and every unlisted country keeps its built-in group. GB and UK are treated as the same country. Invalid data from either a URL or an object logs an error and falls back to 'default' — this never crashes the server. See the Jurisdiction Coverage Map for the full embedded country list, and customComplianceGroup (documented on that page) for routing a profile outside the 8 built-in groups.

A region entry under overriddenRegions can also set requiresSensitiveOptIn: true — a per-region carve-out (not a different group) that denies cpraCategory: 'sensitive' cookies by default for that region while every other cookie in the group still defaults to granted. The embedded map already sets this for Colorado (US.overriddenRegions.CO); add it to any other region your own legal review flags as needing the same treatment:

ts
compliance: {
  type: 'auto',
  geoDataProvider: 'geoip', // or 'maxmind' / 'hosted-geoip-lite' — needed to resolve a US state at all
  complianceMap: {
    version: '2026-01',
    countries: {
      US: {
        complianceGroup: 'opt-out',
        default: 'opt-out-strict',
        overriddenRegions: {
          CO: { complianceGroup: 'opt-out', requiresSensitiveOptIn: true },
        },
      },
    },
  },
}
ℹ️Only meaningful when geoDataProvider actually resolves a region — the default timezone/language heuristic never determines a US state, so this flag is always unset on that path regardless of what the map says.

s3Api

When enabled, Consenti mirrors every locale JSON file to S3 in addition to writing it locally. Two kinds of object get written:

  • profiles/${tenantId}/${profileId}/${version}/${locale}.json — the resolved content for one version snapshot, written whenever that version is saved (mirrors the local profiles/{tenantId}/{profileId}/{version}/ directory).
  • profiles/${tenantId}/${complianceGroup}/pointer.json — a tiny { profileId, version } object, written on activate and removed on deactivate. S3 has no directory-symlink equivalent to the local hot-serve swap, so this pointer plays the same role: it names which version is currently live for a compliance group without duplicating the locale content a second time.
ℹ️Consenti's own /resolve-profile route does not read from S3 or resolve this pointer for you — it only checks the local profilesDir. If you're fronting S3 with a CDN or edge function and want it to serve directly from S3 (bypassing the Node process on the hot path), read pointer.json for the compliance group there and fetch the versioned key it names — that's the wireable contract this config produces.

S3 signing is implemented with node:crypto and fetch — no AWS SDK dependency.

KeyRequiredDescription
enabledyesSet to true to activate S3 sync.
regionyesAWS region, e.g. 'us-east-1'.
bucketNameyesS3 bucket name. Must allow PutObject on profiles/*.
accessKeyIdyesAWS access key ID.
secretAccessKeyyesAWS secret access key.
sessionTokennoAWS session token. Required only for temporary credentials (STS assume-role).
s3Api example
ts
s3Api: {
  enabled: true,
  region: 'us-east-1',
  bucketName: 'my-cmp-profiles',
  accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
  secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,
}

handleCache

A callback invoked whenever Consenti writes or removes locale JSON files on disk. Use it to integrate with a CDN, nginx proxy cache, or any reverse proxy that caches the hot-serve profile files.

This is the config-based alternative to listening on the cache:warm and cache:purge EventBus events — use whichever fits your integration style.

ArgumentTypeDescription
pathsstring[]Absolute file paths written or removed in this operation.
profileIdstringId of the profile that triggered the operation. It stays the same across edits of that profile.
isPurgebooleantrue — files were removed (deactivate / delete / update); false — files were written (create / activate).
handleCache example
ts
handleCache: (paths, profileId, isPurge) => {
  if (isPurge) {
    // Purge these paths from your CDN / nginx proxy cache
    for (const p of paths) {
      cdnClient.purge(p)
    }
  } else {
    // Warm the cache at these paths
    for (const p of paths) {
      cdnClient.warm(p)
    }
  }
}

Worked example — CloudFront in front of S3

s3Api and handleCache compose into a full CDN-fronted setup: Consenti writes the resolved profiles to S3, CloudFront serves them at the edge, and handleCache tells CloudFront exactly what changed on every activate/deactivate.

  1. Point a CloudFront distribution's origin at the s3Api.bucketName bucket (Origin Access Control, bucket not public).
  2. Cache /profiles/${tenantId}/${profileId}/${version}/${locale}.json aggressively — that path is content-addressed by version, so it never changes once written. A long max-age (or CloudFront default TTL) is safe.
  3. Cache /profiles/${tenantId}/${complianceGroup}/pointer.json with a short TTL, or none at all — it's the one mutable file, rewritten on every activate/deactivate, and it's what tells your edge logic (or the requesting client) which version is currently live.
  4. Use handleCache to invalidate the pointer path in CloudFront the instant it changes, instead of waiting out its TTL.
handleCache — CloudFront invalidation
ts
import { CloudFrontClient, CreateInvalidationCommand } from '@aws-sdk/client-cloudfront'

const cloudfront = new CloudFrontClient({ region: 'us-east-1' })

handleCache: async (paths, _profileId, isPurge) => {
  // Only the pointer.json paths need active invalidation — versioned locale
  // files are immutable and can just expire out of cache on their own TTL.
  const pointerPaths = paths.filter(p => p.endsWith('pointer.json'))
  if (pointerPaths.length === 0) return

  await cloudfront.send(new CreateInvalidationCommand({
    DistributionId: process.env.CLOUDFRONT_DISTRIBUTION_ID!,
    InvalidationBatch: {
      CallerReference: `${Date.now()}`,
      Paths: {
        Quantity: pointerPaths.length,
        Items: pointerPaths.map(p => `/${p.replace(/^.*\/profiles\//, 'profiles/')}`),
      },
    },
  }))
}
ℹ️handleCache fires for any cache integration — nginx, another CDN, a custom edge function — CloudFront here is just the concrete example. The two paths in the contract are always the same: ${profileId}/${version}/${locale}.json (immutable, safe to cache forever) and ${complianceGroup}/pointer.json (mutable, must be invalidated or served with a short TTL). See the s3Api section above for what each path contains.
⚠️A CDN in front of S3 only ever serves the static profile JSON. The dynamic routes — /resolve-profile (geo-resolution) and /consent (record writes) — always hit the Consenti origin server directly; they cannot be served from S3 or cached at the edge. Point the widget's api.baseUrl at your origin, not the CDN domain — see the UI api config.

tcf

IAB Transparency & Consent Framework v2.3 integration. When enabled, Consenti fetches the Global Vendor List and stores TC strings on consent records. cmpId/cmpVersion here must match the widget's own compliance.tcf config — the client-side window.__tcfapi stub lives in @consenti/ui, not here; see the "TCF v2.3 Implementation Guide".

KeyTypeDefaultDescription
enabledbooleanfalse (omit tcf to disable)Enable TCF mode. Starts the GVL refresh background job on boot.
cmpIdnumber—Your IAB-registered CMP ID. Required when enabled: true.
cmpVersionnumber—Your CMP software version number. Required when enabled: true. Included in TC strings.
publisherCCstring— (no default)ISO 3166-1 alpha-2 country code of the publisher ('DE', 'FR'). Needed for spec-correct binary TC-string encoding (with the optional @iabtechlabtcf/core peer dependency): without it Consenti falls back to a simplified, non-spec TC string. There is no default because there is no honest country to assume on a publisher's behalf. Part of what the dashboard's registration confirmation hashes, so changing it asks you to re-confirm.
tcf example
ts
compliance: {
  tcf: {
    enabled: true,
    cmpId: 42,          // your IAB CMP ID
    cmpVersion: 1,
    publisherCC: 'DE',  // your country — needed for spec-correct TC strings
  },
}
ℹ️Until a dashboard admin confirms the cmpId/cmpVersion registration (Dashboard → Vendors), TCF is treated as disabled: no TC string is produced and the widget installs no window.__tcfapi. See the TCF & GPP Registration Guide.
⚠️TCF mode requires an IAB-registered CMP ID. Do not use a placeholder CMP ID in production — the GVL and downstream DSPs validate it.

gpp

IAB Global Privacy Platform integration, US National (usnat) section only — the umbrella section most MSPA / programmatic US bidders read. State sections (US-CA, US-CO, …) are not encoded separately. When enabled, Consenti stores a gppString on consent records, and the widget exposes window.__gpp. cmpId/cmpVersion here must match the widget's own compliance.gpp config.

KeyTypeDefaultDescription
enabledbooleanfalse (omit gpp to disable)Turn GPP string generation on.
cmpIdnumber— (required)Your CMP ID for GPP. Unlike TCF there is no public IAB list to check it against, so confirming it in the dashboard is a self-attestation.
cmpVersionnumber— (required)Your CMP software version number.
mspaCoveredTransactionboolean— (required)MSPA "covered transaction": whether this deployment's data transactions fall under Multi-State Privacy Agreement signatory obligations. Required, with no default, because there is no honest value to assume — answer it with your legal counsel.
mspaOptOutOptionMode0 | 1 | 2— (required)MSPA opt-out-option mode, IAB's tri-state: 0 not applicable, 1 yes, 2 no.
mspaServiceProviderMode0 | 1 | 2— (required)MSPA service-provider mode, same tri-state: 0 not applicable, 1 yes, 2 no.
gpp example
ts
compliance: {
  gpp: {
    enabled: true,
    cmpId: 9999,
    cmpVersion: 1,
    mspaCoveredTransaction: true,
    mspaOptOutOptionMode: 1,     // 0 = not applicable | 1 = yes | 2 = no
    mspaServiceProviderMode: 2,
  },
}
  • A GPP string is only produced for a consent write when the profile contains cookies tagged cpraCategory: 'sale' or 'sharing'; otherwise there is nothing to encode.
  • Install the optional @iabgpp/cmpapi peer dependency for spec-correct binary encoding. Without it Consenti uses a simplified, non-spec string.
  • Like TCF, GPP stays disabled until a dashboard admin confirms the registration. See the TCF & GPP Registration Guide.

Age gate (per-profile, not a createConsenti() option)

There is no ageGate field on createConsenti() — one global age rule doesn't fit a multi-region deployment (GDPR Article 8's consent age varies 13–16 by EU member state; DPDPA has its own child-data age rules). Age gate is configured per-profile, per-locale, in the dashboard's Profile Editor:

FieldTypeDefaultDescription
enabledbooleanfalseEnable age gating for this profile — Step 1 checkbox.
minimumAgenumber16Minimum age required to record consent. Common values: 13 (COPPA), 16 (GDPR Article 8 default).
requireParentalConsentbooleanfalseWhen true, the widget also fires consenti:parentalConsentRequired with a token on decline, instead of just denying all — see the "Parental consent" section below.

The age-gate modal's heading, body text, and Yes/No button labels are authored per locale in the Main Banner content step, alongside the main banner text — translatable, unlike the widget's own hardcoded UI chrome (close button, locale switcher). The widget reads all of this off the resolved profile (profile.ageGate/profile.ageGateModal) automatically; nothing to configure in createConsenti() or ConsentiSetup().

dataRetention

Automatic GDPR data minimisation. When configured, Consenti runs a nightly background job that hard-deletes consent records older than purgeAfterDays.

KeyTypeDefaultDescription
purgeAfterDaysnumber— (omit to disable)Delete consent records older than this many days. The purge runs once at startup and then every 24 hours.
dataRetention example
ts
// Keep consent records for 1 year, then purge automatically
compliance: {
  dataRetention: {
    purgeAfterDays: 365,
  },
}
ℹ️Audit log entries (audit_logs) are never purged — they are append-only by design. Only consent records are affected by this setting.

multiTenant

When enabled, Consenti resolves the tenant from the request and scopes all data operations to that tenant. The tenant identifier is extracted from the X-Tenant-ID header or the tenantId query parameter.

KeyTypeDefaultDescription
enabledbooleanfalseEnable multi-tenant mode. All requests without a valid tenant ID fall back to "default".
multiTenant example
ts
multiTenant: { enabled: true }

// Clients must then pass:
// X-Tenant-ID: acme-corp
// or: GET /consenti/api/v1/consent?tenantId=acme-corp

maxBodySize

Maximum allowed request body size in bytes. Requests exceeding this limit receive a 413 Payload Too Large response before any parsing occurs.

KeyTypeDefaultDescription
maxBodySizenumber1048576 (1 MB)Body size limit in bytes.
maxBodySize example
ts
maxBodySize: 512_000   // 512 KB

trustedProxies

List of IP addresses or CIDR ranges for reverse proxies that sit in front of Consenti (nginx, Cloudflare, AWS ALB, etc.). When set, Consenti reads the real client IP from the X-Forwarded-For header instead of the TCP connection address. This ensures rate limiting and IP hashing target the actual visitor, not the proxy.

KeyTypeDefaultDescription
trustedProxiesstring[][]IP addresses or CIDR blocks of trusted reverse proxies.
trustedProxies examples
ts
// Single proxy
trustedProxies: ['127.0.0.1']

// Private network (e.g. Kubernetes cluster)
trustedProxies: ['10.0.0.0/8', '172.16.0.0/12', '192.168.0.0/16']
⚠️Only list IPs you actually control. A spoofed X-Forwarded-For from an untrusted source could bypass rate limiting.

plugins

Consenti ships a plugin system that lets you hook into the data lifecycle without modifying the package. Plugins are class instances that extend ConsentiServerPlugin.

plugin skeleton
ts
import {
  ConsentiServerPlugin,
  type PluginContext,
  type CreateConsentInput,
  type ConsentDbRecord,
} from '@consenti/api'

export class AuditWebhookPlugin extends ConsentiServerPlugin {
  name = 'audit-webhook'

  async initialize(ctx: PluginContext): Promise<void> {
    // ctx.storage — direct StorageAdapter access
    // ctx.config  — full ConsentiServerConfig
    console.log('AuditWebhookPlugin ready')
  }

  async afterConsentSave(record: ConsentDbRecord): Promise<void> {
    await fetch('https://webhook.example.com/consent', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(record),
    })
  }

  async destroy(): Promise<void> {
    // clean up connections, timers, etc.
  }
}

// Register it:
plugins: [new AuditWebhookPlugin()]

Available hooks

HookWhen it firesCan mutate?
initialize(ctx)After storage connects, before first request.—
destroy()On server shutdown.—
beforeConsentSave(data)Before a new consent record is written.yes — return modified data
afterConsentSave(record)After a new consent record is persisted.no
beforeConsentUpdate(data)Before an existing consent record is updated.yes — return modified data
afterConsentUpdate(record)After an existing consent record is updated.no
beforeProfileFetch(id)Before a profile is loaded by ID.yes — return a different id
afterProfileFetch(profile)After a profile is loaded.yes — return modified profile
beforeUserCreate(data)Before a new admin user is created.yes — return modified data
afterUserCreate(user)After a new admin user is persisted.no
ℹ️Hooks that return a value (before* hooks) must return the complete modified object — not a partial. Hooks that do not return a value (after* hooks) receive a read-only snapshot; mutating it has no effect on the stored data.

branding

Customises the appearance of the admin dashboard — login page, sidebar, and browser tab title. All fields are optional; defaults give you a standard Consenti-branded experience.

KeyTypeDefaultDescription
appNamestring'Consenti'Product name shown in the dashboard header, login page, and browser tab.
appLogoPathstringConsenti default logoPath to your logo file or a public URL (https://…). When a local file path is given, the file is automatically copied into the dashboard bundle and served as a static asset — no extra route needed.
hidePoweredBybooleanfalseWhen true, removes the "Powered by Consenti" badge from the dashboard footer.
branding examples
ts
// Minimal — just change the app name
branding: {
  appName: 'Acme CMP',
}

// Full white-label — custom name, logo, and hide badge
branding: {
  appName: 'Acme CMP',
  appLogoPath: './assets/acme-logo.svg',  // local file — copied and served automatically
  hidePoweredBy: true,
}

// Logo from a public CDN
branding: {
  appName: 'Acme CMP',
  appLogoPath: 'https://cdn.acme.com/logo.svg',
}
ℹ️Branding is applied once at startup — changing it requires a server restart. The logo and config are written to the built dashboard directory so they are available immediately when the first request hits the SPA.

compliance.dataSigningHash

One secret, four jobs — all server-side, HMAC-SHA256 via node:crypto createHmac: signs every consent record at create/update time (hex-encoded into signature), signs the consenti_{visitorId} ownership cookie, signs parental-consent request/resolve tokens, and salts hashed IPs before hashIp()'s SHA-256 (IPv4 is only 4.2B addresses, so an unsalted hash of even a masked IP is a rainbow-table lookup away from reversal). Independent of the widget's own cookie-signing (core.cookieSigningKey), which protects the visitor's local cookie instead.

⚠️Auto-generated if you don't set it — a random value is generated in memory at boot, so all four jobs above are always active. That generated value isn't persisted, though, so every restart invalidates outstanding ownership cookies, any in-flight parental-consent token, and (if compared across restarts) hashed-IP continuity. Set it explicitly — via compliance.dataSigningHash or the CONSENTI_DATA_SIGNING_HASH env var — for anything beyond local dev.
KeyTypeDefaultDescription
dataSigningHashstringundefinedSigning/salting key. Auto-generated in memory (changes on restart) if omitted.
parentalConsentTokenTtlDaysnumber7Days a parental-consent request/resolve token (from POST /consent/:visitorId/parental-consent-request) stays valid. The tokens are signed with dataSigningHash, so with the auto-generated key a restart invalidates them regardless of this value.
dataSigningHash example
ts
createConsenti({
  compliance: {
    dataSigningHash: process.env.CONSENTI_DATA_SIGNING_HASH,
  },
})

The consent-record signature covers tenantId, visitorId, profileId, locale, and the sorted consentJson entries — not the database-generated id/createdAt/updatedAt. GET /consent/:visitorId/verify checks it when the record has one, adding hmac_invalid to the response's reasons array on mismatch — a record from before this key existed never fails the check for its absence. signature is included in CSV/JSON/XLSX exports and shown in the dashboard's Consents detail view.

ℹ️Hash-chaining between records (a full tamper-evident log, not just per-record integrity) is not implemented — out of scope for now.

Security rules (built-in)

  • Raw IP addresses are never stored — masked (last IPv4 octet, or last 80 bits of IPv6, zeroed) and salted before SHA-256 hashing
  • Passwords hashed with scrypt via node:crypto — not bcrypt (external dep)
  • JWT signed with node:crypto HMAC-SHA256 — not jsonwebtoken package
  • Admin routes always go through auth.middleware.ts
  • Security headers (X-Content-Type-Options, X-Frame-Options, Referrer-Policy) added to every response
  • Stack traces hidden in production (NODE_ENV === 'production')