Database
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.
| Pool | API | Purpose |
|---|---|---|
| Main pool | mysql2/promise | All authentication queries: user lookups, token operations, MFA codes. Supports async/await and transactions via pool.getConnection(). |
| Limiter pool | mysql2 (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:
host, port, user, password, database, connectionLimit, and any other mysql2 pool option.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:
| Function | Returns | Throws if |
|---|---|---|
getPool() | Main promise pool | configuration() not called |
poolForLibrary() | Limiter callback pool | configuration() not called |
getDisposableEmailList() | LMDB database instance | configuration() 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.
| Column | Type | Constraints | Description |
|---|---|---|---|
id | INT | AUTO_INCREMENT PRIMARY KEY | Unique user identifier |
name | VARCHAR(100) | NOT NULL | First name |
last_name | VARCHAR(100) | NOT NULL | Last name |
email | VARCHAR(255) | UNIQUE NOT NULL | Login email. Uniqueness enforced at the database level. |
password_hash | VARCHAR(255) | NOT NULL | Argon2id hash of the user's password |
avatar | VARCHAR(200) | Nullable | URL or path to the user's avatar image |
provider | VARCHAR(50) | Nullable | OAuth provider name (e.g., google, github). NULL for email/password accounts. |
provider_id | VARCHAR(100) | Nullable | The user's ID at the OAuth provider. NULL for email/password accounts. |
active_user | BOOLEAN | DEFAULT 1 | Whether the account is active. 0 disables login without deleting the row. |
remember_user | BOOLEAN | DEFAULT 0 | Whether the user opted into persistent sessions |
terms_and_privacy_agreement | BOOLEAN | DEFAULT 0 | Whether the user accepted terms of service |
accepts_marketing | BOOLEAN | DEFAULT 0 | Whether the user opted into marketing emails |
last_mfa_at | DATETIME | Nullable | Timestamp of the last successful MFA verification. Set to NULL when a refresh token expires, forcing MFA on the next login. |
country | VARCHAR(100) | Nullable | User's country (profile data) |
city | VARCHAR(100) | Nullable | User's city |
address | VARCHAR(200) | Nullable | Street address |
zip | VARCHAR(100) | Nullable | Postal code |
district | VARCHAR(100) | Nullable | District or region |
visitor_id | CHAR(36) | NOT NULL | Foreign key to visitors(visitor_id). Links the user to their device fingerprint record managed by the bot-detector. |
created_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP | Account creation timestamp |
updated_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP | Last 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.
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:
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.
| Column | Type | Constraints | Description |
|---|---|---|---|
id | INT | AUTO_INCREMENT PRIMARY KEY | Unique row identifier |
user_id | INT | NOT NULL | Foreign key to users(id) |
api_token | VARCHAR(300) | NOT NULL UNIQUE | SHA-256 hex hash of the raw API token. The raw token is never stored. |
public_identifier | VARCHAR(300) | NOT NULL UNIQUE | Non-secret public reference used by authenticated management actions |
prefix | VARCHAR(50) | NOT NULL | Prefix segment from the raw token format prefix_random_checksum |
name | VARCHAR(150) | NOT NULL | Friendly token label shown in dashboards and metadata responses |
created_at | TIMESTAMP(3) | DEFAULT CURRENT_TIMESTAMP(3) | When the token row was inserted |
expires_at | TIMESTAMP(3) | Nullable | Optional expiry timestamp. NULL means the token does not expire. |
restricted_to_ip_address | VARCHAR(200) | Nullable | JSON-encoded array of allowed IP addresses, or NULL when no host restriction exists |
last_used | TIMESTAMP(3) | Nullable | Initialized when the token is created and updated after successful external verification |
privilege_type | ENUM('demo', 'restricted', 'protected', 'full', 'custom') | NOT NULL DEFAULT 'restricted' | Required privilege scope for successful verification |
usage_count | INT | DEFAULT 0 | Number of successful external verification calls recorded for the token |
valid | BOOLEAN | DEFAULT 0 | Whether 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:
| Index | Columns | Purpose |
|---|---|---|
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 |
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.
| Column | Type | Constraints | Description |
|---|---|---|---|
id | INT | AUTO_INCREMENT PRIMARY KEY | Unique row identifier |
user_id | INT | NOT NULL | Foreign key to users(id) |
token | VARCHAR(600) | NOT NULL UNIQUE | SHA-256 hex hash of the raw refresh token. The raw token is never stored. |
valid | BOOLEAN | DEFAULT 0 | Whether the token can still be used. Set to 1 on creation, 0 on revocation or consumption. |
created_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP | When the token row was inserted |
expiresAt | TIMESTAMP | NOT NULL | Computed from the refresh_ttl configuration at generation time |
usage_count | INT | DEFAULT 0 | 0 means fresh, 1 means consumed. Any value above 1 triggers reuse detection and revokes all user sessions. |
session_started_at | TIMESTAMP | Nullable | Set 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.
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.
| Column | Type | Constraints | Description |
|---|---|---|---|
id | INT | AUTO_INCREMENT PRIMARY KEY | Unique row identifier |
user_id | INT | NOT NULL UNIQUE | Foreign 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. |
token | VARCHAR(600) | NOT NULL UNIQUE | The refresh token hash associated with this MFA challenge. Links the MFA flow to the session that triggered it. |
jti | VARCHAR(500) | NOT NULL UNIQUE | JWT ID of the temporary access token issued during the MFA-pending state. Used to invalidate the temporary token after MFA completes. |
code_hash | CHAR(64) | NOT NULL UNIQUE | SHA-256 hash of the 7-digit MFA code sent to the user. The raw code is never stored. |
expires_at | DATETIME | NOT NULL | When this MFA challenge expires. After this time, the user must request a new code. |
used | BOOLEAN | DEFAULT 0 | Whether the code has been successfully verified. Prevents replay of a valid code within the expiry window. |
created_at | DATETIME | DEFAULT CURRENT_TIMESTAMP | When the MFA challenge was created |
Indexes
The table has four indexes beyond the primary key to support the common query patterns:
| Column | Purpose |
|---|---|
user_id | Fast lookup by user when creating or replacing a pending MFA code |
code_hash | Fast verification lookup when a user submits a code |
token | Join with refresh_tokens table to validate the associated session |
used | Filter 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.
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.
| Column | Type | Description |
|---|---|---|
visitor_id | CHAR(36) | UUID generated by DEFAULT (UUID()). Referenced by users.visitor_id. UNIQUE. |
canary_id | VARCHAR(64) | Primary key. The fingerprint identifier set as a cookie by the bot-detector. |
ip_address | VARCHAR(45) | Client IP (supports IPv6 length) |
user_agent | TEXT | Raw User-Agent header |
country, region, region_name, city, district | VARCHAR | Geo-IP location data |
lat, lon | VARCHAR(150) | GPS coordinates from geo-IP lookup |
timezone, currency | VARCHAR(64) | Timezone and currency from geo-IP |
isp, org, as_org | VARCHAR(64) | Network information (ISP, organization, AS) |
device_type, browser, browserType, browserVersion | VARCHAR(64) | Device and browser classification |
deviceVendor, deviceModel, os | VARCHAR(64) | Device hardware and OS. Default 'unknown'. |
proxy, hosting | BOOLEAN | Whether the IP is a known proxy or hosting provider |
proxy_allowed, hosting_allowed | BOOLEAN | Whether the proxy/hosting status has been manually allowed. Default false. |
is_bot | BOOLEAN | Whether the visitor is classified as a bot. Default false. |
suspicious_activity_score | INT | Cumulative suspicion score. Default 0. Used by the IAM service's anomaly detection engine. |
first_seen | TIMESTAMP | When the visitor was first recorded |
last_seen | TIMESTAMP | Auto-updated on every interaction via ON UPDATE CURRENT_TIMESTAMP |
request_count | INT | Total number of requests from this visitor. Default 1. |
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:
500.900000). Typically set to match the access token JWT expiry.The LRU cache provides two benefits:
- 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. - Immediate revocation. Deleting an entry from the cache makes the next verification fail immediately, without waiting for JWT expiry.
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
UNIQUEonuser_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.