Database

How the IAM service organizes its MySQL schema, connection pools, hashing utilities, and in-memory caches across four IAM tables, two pool types, and an LRU access token store.

The IAM service stores all persistent auth state in MySQL. The IAM schema includes four tables: users for account data, api_tokens for machine-to-machine credentials, refresh_tokens for session credentials, and mfa_codes for pending multi-factor authentication challenges. A fifth related table, visitors, is created and managed by the bot-detector service but referenced by a foreign key from users.

Access tokens are not stored in the database. They live exclusively in an in-memory LRU cache with a configurable maximum size and TTL. This keeps token verification fast and makes individual token revocation immediate without database writes.

The service maintains two MySQL connection pools: a promise-based pool for all auth operations and a callback-based pool for rate limiting. A third data store, an LMDB key-value database, holds a read-only list of disposable email domains used during signup validation.


Connection Architecture

Pool Types

The service creates two separate MySQL pools at startup. They serve different purposes and use different MySQL2 APIs.

PoolAPIPurpose
Main poolmysql2/promiseAll authentication queries: user lookups, token operations, MFA codes. Supports async/await and transactions via pool.getConnection().
Limiter poolmysql2 (callback)Rate limiting storage. Uses the callback API because the rate limiter library (rate-limiter-flexible) requires a callback-style MySQL connection.

Both pools are configured from the store section of the service configuration:

store.main
mysql.PoolOptions required
MySQL2 pool options for the main authentication pool. Includes host, port, user, password, database, connectionLimit, and any other mysql2 pool option.
store.rate_limiters_pool.store
mysql.PoolOptions required
MySQL2 pool options for the rate limiter pool. Same option format as the main pool, but typically points to a separate database to isolate rate limit state from auth data.
store.rate_limiters_pool.dbName
string required
The database name used by the rate limiter pool. The service uses this value to create the database if it does not exist.

Disposable Email Store

The third data connection is an LMDB database opened in read-only, compressed mode. It contains a precomputed list of disposable email domains (services like Mailinator, Guerrilla Mail, etc.). During signup, the service extracts the domain from the user's email address and checks it against this store. If the domain is found, the signup request is rejected.

LMDB is a memory-mapped key-value store that does not require a separate server process. The data file is bundled with the shield-base-cli package and loaded at startup.

Initialization Order

All three stores are initialized inside the configuration() function, which must be called before any route handler runs:

Main pool

mysql2.createPool(config.store.main) creates the promise-based pool for all auth queries.

Limiter pool

mysql.createPool(config.store.rate_limiters_pool.store) creates the callback-based pool for rate limiting.

Email list database

LMDB open() loads the disposable email domain list in read-only mode.

The service exports accessor functions for each store. All three throw if called before configuration() has run:

FunctionReturnsThrows if
getPool()Main promise poolconfiguration() not called
poolForLibrary()Limiter callback poolconfiguration() not called
getDisposableEmailList()LMDB database instanceconfiguration() not called

Schema

users Table

The users table stores account credentials, profile data, OAuth provider references, and a foreign key to the visitors table managed by the bot-detector.

ColumnTypeConstraintsDescription
idINTAUTO_INCREMENT PRIMARY KEYUnique user identifier
nameVARCHAR(100)NOT NULLFirst name
last_nameVARCHAR(100)NOT NULLLast name
emailVARCHAR(255)UNIQUE NOT NULLLogin email. Uniqueness enforced at the database level.
password_hashVARCHAR(255)NOT NULLArgon2id hash of the user's password
avatarVARCHAR(200)NullableURL or path to the user's avatar image
providerVARCHAR(50)NullableOAuth provider name (e.g., google, github). NULL for email/password accounts.
provider_idVARCHAR(100)NullableThe user's ID at the OAuth provider. NULL for email/password accounts.
active_userBOOLEANDEFAULT 1Whether the account is active. 0 disables login without deleting the row.
remember_userBOOLEANDEFAULT 0Whether the user opted into persistent sessions
terms_and_privacy_agreementBOOLEANDEFAULT 0Whether the user accepted terms of service
accepts_marketingBOOLEANDEFAULT 0Whether the user opted into marketing emails
last_mfa_atDATETIMENullableTimestamp of the last successful MFA verification. Set to NULL when a refresh token expires, forcing MFA on the next login.
countryVARCHAR(100)NullableUser's country (profile data)
cityVARCHAR(100)NullableUser's city
addressVARCHAR(200)NullableStreet address
zipVARCHAR(100)NullablePostal code
districtVARCHAR(100)NullableDistrict or region
visitor_idCHAR(36)NOT NULLForeign key to visitors(visitor_id). Links the user to their device fingerprint record managed by the bot-detector.
created_atTIMESTAMPDEFAULT CURRENT_TIMESTAMPAccount creation timestamp
updated_atTIMESTAMPDEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMPLast modification timestamp. Updated automatically by MySQL on any column change.

Foreign Key: visitor_id

FOREIGN KEY (visitor_id) REFERENCES visitors(visitor_id)
  ON UPDATE CASCADE
  ON DELETE RESTRICT

The ON DELETE RESTRICT constraint prevents deleting a visitor record while a user row still references it. The ON UPDATE CASCADE constraint propagates visitor ID changes (if any) to the user row automatically. This ensures the link between a user and their device fingerprint is never broken.

The visitor_id column requires that the visitor already exists in the visitors table before a user can be created. The bot-detector service creates visitor records during its fingerprinting flow, which runs before signup. If you are running the IAM service without the bot-detector, you must populate the visitors table manually.

Password Hashing

Passwords are hashed with Argon2id before storage. The password configuration section controls the hashing parameters:

password.pepper
string required
A server-side secret mixed into every password hash. The pepper is not stored in the database and is the same for all users. If you lose it, all existing password hashes become unverifiable.
password.hashLength
number
Output length of the Argon2id hash in bytes. Optional; defaults to the Argon2 library default.
password.timeCost
number
Number of Argon2id iterations. Higher values increase resistance to brute force at the cost of CPU time per login. Optional.
password.memoryCost
number
Memory usage in KiB for each Argon2id hash. Higher values increase resistance to GPU-based attacks. Optional.

api_tokens Table

The api_tokens table stores hashed machine-to-machine credentials together with the public identifier and metadata used by the verification and dashboard management flows. For the full lifecycle and route behavior, see API Tokens.

ColumnTypeConstraintsDescription
idINTAUTO_INCREMENT PRIMARY KEYUnique row identifier
user_idINTNOT NULLForeign key to users(id)
api_tokenVARCHAR(300)NOT NULL UNIQUESHA-256 hex hash of the raw API token. The raw token is never stored.
public_identifierVARCHAR(300)NOT NULL UNIQUENon-secret public reference used by authenticated management actions
prefixVARCHAR(50)NOT NULLPrefix segment from the raw token format prefix_random_checksum
nameVARCHAR(150)NOT NULLFriendly token label shown in dashboards and metadata responses
created_atTIMESTAMP(3)DEFAULT CURRENT_TIMESTAMP(3)When the token row was inserted
expires_atTIMESTAMP(3)NullableOptional expiry timestamp. NULL means the token does not expire.
restricted_to_ip_addressVARCHAR(200)NullableJSON-encoded array of allowed IP addresses, or NULL when no host restriction exists
last_usedTIMESTAMP(3)NullableInitialized when the token is created and updated after successful external verification
privilege_typeENUM('demo', 'restricted', 'protected', 'full', 'custom')NOT NULL DEFAULT 'restricted'Required privilege scope for successful verification
usage_countINTDEFAULT 0Number of successful external verification calls recorded for the token
validBOOLEANDEFAULT 0Whether the token can still be used. New tokens are inserted as valid.

Foreign key: user_id

CONSTRAINT users_api_keys
  FOREIGN KEY (user_id) REFERENCES users(id)
  ON DELETE CASCADE

The ON DELETE CASCADE constraint ensures that deleting a user automatically deletes all of their API tokens. No orphaned machine-to-machine credentials remain after account deletion.

Indexes

The service creates three explicit indexes for the most common API-token lookup patterns:

IndexColumnsPurpose
idx_user_api_key_public_identifier(user_id, public_identifier)Speeds up authenticated token-manager lookups that resolve a user's token from the public identifier
idx_user_api_key(user_id, id)Speeds up dashboard actions that resolve a token by owner and token ID
idx_user_valid_tokens(id, valid)Speeds up valid-row checks during management actions
The api_token column stores only a SHA-256 hash of the raw token. The public_identifier is intentionally not hashed because it is not treated as a secret. The createApiKey() helper inserts new rows with valid = 1 and initializes last_used with UTC_TIMESTAMP().

refresh_tokens Table

The refresh_tokens table holds hashed refresh tokens, their validity state, and usage metadata. For a full guide on the refresh token lifecycle (generation, verification, consumption, rotation, revocation), see Refresh Tokens.

ColumnTypeConstraintsDescription
idINTAUTO_INCREMENT PRIMARY KEYUnique row identifier
user_idINTNOT NULLForeign key to users(id)
tokenVARCHAR(600)NOT NULL UNIQUESHA-256 hex hash of the raw refresh token. The raw token is never stored.
validBOOLEANDEFAULT 0Whether the token can still be used. Set to 1 on creation, 0 on revocation or consumption.
created_atTIMESTAMPDEFAULT CURRENT_TIMESTAMPWhen the token row was inserted
expiresAtTIMESTAMPNOT NULLComputed from the refresh_ttl configuration at generation time
usage_countINTDEFAULT 00 means fresh, 1 means consumed. Any value above 1 triggers reuse detection and revokes all user sessions.
session_started_atTIMESTAMPNullableSet once at creation, never updated during rotation. Used to enforce MAX_SESSION_LIFE.

Foreign Key: user_id

FOREIGN KEY (user_id) REFERENCES users(id)
  ON DELETE CASCADE

The ON DELETE CASCADE constraint ensures that deleting a user automatically deletes all their refresh tokens. No orphaned sessions remain after account deletion.

The token column stores a SHA-256 hash, not the raw token. The raw token (64 random bytes, hex encoded) is sent to the client exactly once as an httpOnly cookie named session and is never stored in plaintext on the server. See Refresh Tokens for the hashing pipeline.

mfa_codes Table

The mfa_codes table stores pending MFA verification codes. Each row represents a single challenge that a user must complete to finalize authentication. Rows are deleted after successful verification or when they expire.

ColumnTypeConstraintsDescription
idINTAUTO_INCREMENT PRIMARY KEYUnique row identifier
user_idINTNOT NULL UNIQUEForeign key to users(id). The UNIQUE constraint means each user can have at most one pending MFA code at a time. Creating a new code for the same user replaces the previous one.
tokenVARCHAR(600)NOT NULL UNIQUEThe refresh token hash associated with this MFA challenge. Links the MFA flow to the session that triggered it.
jtiVARCHAR(500)NOT NULL UNIQUEJWT ID of the temporary access token issued during the MFA-pending state. Used to invalidate the temporary token after MFA completes.
code_hashCHAR(64)NOT NULL UNIQUESHA-256 hash of the 7-digit MFA code sent to the user. The raw code is never stored.
expires_atDATETIMENOT NULLWhen this MFA challenge expires. After this time, the user must request a new code.
usedBOOLEANDEFAULT 0Whether the code has been successfully verified. Prevents replay of a valid code within the expiry window.
created_atDATETIMEDEFAULT CURRENT_TIMESTAMPWhen the MFA challenge was created

Indexes

The table has four indexes beyond the primary key to support the common query patterns:

ColumnPurpose
user_idFast lookup by user when creating or replacing a pending MFA code
code_hashFast verification lookup when a user submits a code
tokenJoin with refresh_tokens table to validate the associated session
usedFilter for unused codes during cleanup

Foreign Keys

CONSTRAINT users_mfa
  FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE

CONSTRAINT token_mfa
  FOREIGN KEY (token) REFERENCES refresh_tokens(token) ON DELETE CASCADE

Both cascade on delete. Deleting a user wipes their pending MFA codes. Deleting (or revoking) the refresh token that initiated the MFA flow also removes the pending code, because the session no longer exists.

The user_id column has a UNIQUE constraint. If a user requests a new MFA code while one is already pending, the service must delete or replace the existing row first. The insertion will fail at the database level if two rows share the same user_id.

visitors Table (External)

The visitors table is created and managed by the bot-detector service, not by the IAM service. It is documented here because the users table references it through a foreign key.

ColumnTypeDescription
visitor_idCHAR(36)UUID generated by DEFAULT (UUID()). Referenced by users.visitor_id. UNIQUE.
canary_idVARCHAR(64)Primary key. The fingerprint identifier set as a cookie by the bot-detector.
ip_addressVARCHAR(45)Client IP (supports IPv6 length)
user_agentTEXTRaw User-Agent header
country, region, region_name, city, districtVARCHARGeo-IP location data
lat, lonVARCHAR(150)GPS coordinates from geo-IP lookup
timezone, currencyVARCHAR(64)Timezone and currency from geo-IP
isp, org, as_orgVARCHAR(64)Network information (ISP, organization, AS)
device_type, browser, browserType, browserVersionVARCHAR(64)Device and browser classification
deviceVendor, deviceModel, osVARCHAR(64)Device hardware and OS. Default 'unknown'.
proxy, hostingBOOLEANWhether the IP is a known proxy or hosting provider
proxy_allowed, hosting_allowedBOOLEANWhether the proxy/hosting status has been manually allowed. Default false.
is_botBOOLEANWhether the visitor is classified as a bot. Default false.
suspicious_activity_scoreINTCumulative suspicion score. Default 0. Used by the IAM service's anomaly detection engine.
first_seenTIMESTAMPWhen the visitor was first recorded
last_seenTIMESTAMPAuto-updated on every interaction via ON UPDATE CURRENT_TIMESTAMP
request_countINTTotal number of requests from this visitor. Default 1.
The canary_id is the primary key of the visitors table, not visitor_id. The visitor_id is a separate UUID used as the foreign key target for users.visitor_id. This design allows the bot-detector to identify visitors by their fingerprint cookie (canary_id) while giving the IAM service a stable UUID reference (visitor_id) that does not change when the fingerprint rotates.

Access Token Cache

Access tokens are not stored in any database table. Instead, the service maintains an in-memory LRU cache. When an access token is generated, it is added to the cache. When a request arrives with an access token, the service checks the cache first. If the token is present and has not expired, it is valid. If it is not in the cache, the token is verified cryptographically from the JWT signature.

The cache is configured from the JWT configuration section:

jwt.access_tokens.maxCacheEntries
number
Maximum number of access tokens kept in the LRU cache. When the cache is full, the least recently used entry is evicted. Defaults to 500.
jwt.access_tokens.expiresInMs
number
Time-to-live for each cache entry in milliseconds. Entries older than this value are evicted regardless of access frequency. Defaults to 15 minutes (900000). Typically set to match the access token JWT expiry.

The LRU cache provides two benefits:

  1. Early rejection and metadata binding. Missing or invalid cache entries fail before JWT verification, and valid cache entries provide the expected userId, jti, visitorId, and roles used during claim validation.
  2. Immediate revocation. Deleting an entry from the cache makes the next verification fail immediately, without waiting for JWT expiry.
The LRU cache is per-process and not shared across instances. If you run multiple IAM service instances behind a load balancer, a token revoked on one instance remains valid in the caches of other instances until it expires or is evicted. For single-instance deployments, the cache provides strong revocation guarantees.

Hashing Utilities

The service uses two utility functions for all token hashing operations. Both enforce SHA-256 hex format and are used by refresh token, MFA code, and access token flows.

toDigestHex

Accepts any string input and ensures it becomes a SHA-256 hex digest. If the input is already a 64-character hex string matching the SHA-256 pattern, it passes through unchanged. Otherwise, the function computes sha256(input) and returns the hex digest.

import { toDigestHex } from '@riavzon/auth'

const result = await toDigestHex(rawToken)
// result.input the SHA-256 hex string
// result.wasHashed true if the input was already a valid SHA-256 hex,
//                    false if the function had to compute the hash

This function uses Zod validation internally to determine whether the input matches the SHA-256 hex pattern. The idempotent design means you can safely pass either a raw token or an already-hashed value without producing a double hash.

ensureSha256Hex

Strict validation function. Checks that the input is already a valid SHA-256 hex string (exactly 64 lowercase hex characters). If the validation fails, it throws an error. This function does not compute a hash; it only validates format.

import { ensureSha256Hex } from '@riavzon/auth'

ensureSha256Hex(tokenHash) // throws if not a valid SHA-256 hex string

Used as a guard before database operations to ensure that only properly formatted hashes reach the token and code_hash columns.


Entity Relationship

The five related tables form a simple hierarchy. The bot-detector's visitors table sits at the root and the IAM service owns the other four:

visitors (bot-detector)
  └── users (IAM)
  ├── api_tokens (IAM)
        ├── refresh_tokens (IAM)
        │     └── mfa_codes (IAM, via token FK)
        └── mfa_codes (IAM, via user_id FK)
  • A visitor can have zero or more users though typically one.
  • A user can have zero or more API tokens.
  • A user can have zero or more refresh tokens one per active session/device.
  • A user can have at most one pending MFA code enforced by UNIQUE on user_id.
  • An MFA code is also linked to a specific refresh token the session that triggered the MFA challenge.

Cascade deletes flow downward: deleting a visitor is blocked, deleting a user cascades to their API tokens, refresh tokens, and MFA codes, and deleting a refresh token cascades to any MFA code tied to that session.

Logo