This document describes the security features, compliance posture, and best practices for deploying Unchained Engine in security-sensitive environments including banking, government, and enterprise contexts.
If you discover a security vulnerability, please report it responsibly:
- Email: hello@unchained.shop
- Do NOT open public GitHub issues for security vulnerabilities
We will acknowledge receipt within 48 hours and provide a detailed response within 7 days.
This section describes how Unchained Engine can support compliance efforts. Note: Compliance certifications (PCI DSS, ISO 27001, SOC 2, etc.) are issued to organizations and their processes, not to software products. The controls described here can help organizations meet technical requirements.
| Standard | Support Level | What This Means |
|---|---|---|
| PCI DSS SAQ-A | Compatible | No card data storage; uses tokenization. Eligibility depends on your full deployment. |
| ISO 27001 | Technical Controls | Implements access control, audit logging, and cryptographic standards. ISMS policies and processes are your responsibility. |
| FIPS 140-3 | Deployment-dependent | Password hashing and local token signing use Node.js cryptography. Validate the runtime and every enabled integration for your deployment. |
| SOC 2 | Audit Support | Emits an OCSF audit event stream for evidence collection in your SIEM. SOC 2 audits evaluate your organization's controls, not software. |
| FINMA 2023/1 | Technical Controls | Audit logging, access control, and cryptography support ICT risk management requirements. The circular is principle-based; organizational controls are your responsibility. |
| GDPR | Technical Measures | Access control and audit events support data-protection processes; retention and processing records are your responsibility. |
The following sections describe the cryptography used by the built-in authentication and payment integrations.
- Algorithm: PBKDF2 with SHA-512
- Iterations: 300,000
- Salt: 16 bytes, cryptographically random
- Key Length: 256 bits (32 bytes)
- Implementation: Web Crypto API (
crypto.subtle)
// packages/core-users/src/module/pbkdf2.ts
const PBKDF2_ITERATIONS = 300000;
const PBKDF2_KEY_LENGTH = 256; // Bits, as required by deriveBits()
const PBKDF2_SALT_LENGTH = 16;
// Uses SHA-512 via crypto.subtle.deriveBits()- API Keys:
createAccessToken()generates a UUIDv4 usingcrypto.randomUUID()and stores its SHA-256 hash. UUIDv4 contains 122 random bits. These keys are reusable and have no automatic expiry; generating a replacement overwrites the user's existing key. - Email Verification and Password Reset Tokens: UUIDv4 tokens stored as SHA-256 hashes, time-limited and consumed when used successfully.
- Login Tokens: HS256-signed JWTs with a one-hour default lifetime, controlled by
UNCHAINED_TOKEN_EXPIRY_SECONDS.
Why SHA-256 for Tokens (not PBKDF2)?
Password hashing uses PBKDF2 to slow guesses against user-chosen passwords. Random API keys use SHA-256 for database lookup; their resistance to guessing depends on the randomly generated token. setAccessToken() accepts caller-supplied values, so use a cryptographically random value or prefer createAccessToken().
// packages/core-users/src/module/configureUsersModule.ts
// Preferred: Server generates high-entropy token
const result = await modules.users.createAccessToken('admin');
if (result) console.log(result.token); // Returned only when the user exists- Hash IDs: Generated using
crypto.getRandomValues()(CSPRNG) - Nonces:
crypto.randomUUID()for WebAuthn/Web3 challenges
- Algorithm: HS256 (HMAC-SHA-256), implemented with
jose - Secret:
UNCHAINED_TOKEN_SECRET, validated to contain at least 32 characters - Transport: HttpOnly cookie or Bearer token
- Revocation: Token-version checks and tracked login sessions support invalidation
JWT payloads are signed, not encrypted. Do not add confidential data to their claims.
- HMAC-SHA-256: Datatrans, Payrexx, GridFS uploads
- HMAC-SHA-512: PostFinance Checkout
Full support for passwordless authentication via the WebAuthn standard, enabling hardware security keys and platform authenticators.
Unchained Engine itself is not FIPS-validated. Password hashing and local token signing use Node.js cryptography, but runtime choice alone does not establish compliance for every plugin or authentication method. Assess the enabled integrations with your validated runtime.
Use a runtime with a correctly installed OpenSSL FIPS provider and its generated module configuration. Follow the Node.js FIPS configuration instructions, including the provider installation and nodejs_conf settings required by your Node.js version:
# Point to the complete provider configuration for this runtime
export OPENSSL_CONF=/path/to/nodejs-fips.cnf
node --enable-fips your-app.jsimport crypto from 'node:crypto';
// Check if FIPS mode is enabled
console.log('FIPS mode:', crypto.getFips() === 1 ? 'enabled' : 'disabled');On OpenSSL 3, getFips() reports the active property-query state; it does not establish that a validated provider is loaded. Validate the provider and runtime separately, as described in the Node.js documentation.
-
Password Hashing: PBKDF2-SHA512 runs through Node.js Web Crypto; validate it with the runtime used in your deployment.
-
Third-Party Libraries: Verify that any additional npm packages you add use Node.js crypto APIs or are otherwise FIPS-compliant.
-
Cryptocurrency and Web3 Integrations: JavaScript implementations in
@noble/curvesand@noble/hashesdo not run through Node.js's OpenSSL provider. Review these integrations separately before enabling them in a FIPS deployment.
Unchained implements comprehensive RBAC with:
- Named actions defined in the API role registry
- Built-in roles:
admin,loggedIn, andall(including anonymous access) - Ownership validation: Role rules check ownership for user-scoped resources; administrators can access additional resources
- Field-level permissions: GraphQL type resolvers enforce access
// Example permission check
role.allow(actions.updateOrder, async (obj, params, context) => {
const order = await context.modules.orders.findOrder({ orderId: params.orderId });
return Boolean(context.userId && order?.userId === context.userId);
});All GraphQL mutations are protected with permission checks:
// packages/api/src/resolvers/mutations/index.ts
export default {
logout: acl(actions.logout)(logout),
createUser: acl(actions.createUser)(createUser),
setRoles: acl(actions.manageUsers)(setRoles),
// ... all mutations protected
};Built-in card integrations delegate card entry and processing to payment providers. PCI DSS scope and SAQ eligibility depend on the complete storefront, hosting environment, and enabled integrations.
Built-in card integrations store provider tokens and metadata rather than card numbers or CVV/CVC codes. Do not add card data to custom fields, payment contexts, or logs.
Examples of payment integrations that keep card entry with the provider:
| Provider | Tokenization Method |
|---|---|
| Stripe | PaymentIntent / SetupIntent |
| Datatrans | Secure Fields |
| Saferpay | Redirect with token |
// packages/core-payment/src/db/PaymentCredentialsCollection.ts
type PaymentCredentials = {
paymentProviderId: string;
userId: string;
token?: string; // Provider-issued token only
isPreferred?: boolean;
meta: any; // Provider metadata
};// Secure defaults
{
httpOnly: true, // Prevent XSS access
secure: true, // HTTPS only (unless explicitly disabled)
sameSite: 'lax', // OWASP: CSRF protection
maxAge: 3600 * 1000, // follows UNCHAINED_TOKEN_EXPIRY_SECONDS (1 hour default)
}| Variable | Purpose | Default |
|---|---|---|
UNCHAINED_TOKEN_SECRET |
JWT signing (min 32 chars) | Required for local login tokens |
UNCHAINED_TOKEN_EXPIRY_SECONDS |
JWT lifetime in seconds | 3600 |
UNCHAINED_COOKIE_NAME |
Cookie name | unchained_token |
UNCHAINED_COOKIE_DOMAIN |
Cookie domain restriction | - |
UNCHAINED_COOKIE_SAMESITE |
SameSite attribute | lax |
UNCHAINED_COOKIE_INSECURE |
Any nonempty value disables the secure flag (development only) | Unset |
Authentication failures use InvalidCredentials, and invalid reset/verification tokens use "Token invalid or expired". ACL failures use NoPermissionError with a message identifying the user, denied action, and resolver; those identifiers can also appear in error extensions. See the error definitions and ACL implementation.
Error payloads and logs can include metadata or stack traces. Review custom errors, plugin logging, and log access controls before exposing them outside your deployment.
Use escapeRegexString when constructing a regular expression that should match user input literally:
import { escapeRegexString } from '@unchainedshop/mongodb';
// User input is escaped before regex construction
const regex = new RegExp(escapeRegexString(userInput), 'i');escapeRegexString escapes regex metacharacters and throws TypeError for non-string input. It does not reject empty or long strings. The separate insensitiveTrimmedRegexOperator helper trims input, rejects an empty result, and caps the escaped string at 255 characters before building an anchored case-insensitive expression. See the implementation.
Custom query builders must validate inputs and avoid treating user-supplied text as operators or executable expressions.
Unchained does not enforce GraphQL query-complexity, depth, alias-count, or rate limits by default. As a headless engine embedded in the integrator's own server process, these edge protections are a deployment responsibility — correct thresholds depend on your schema extensions, expected traffic, and infrastructure.
This matters because:
- Anonymous access is enabled by default for read-only storefront queries (
assortments,products,filters,languages,currencies,countries,search), gated inpackages/api/src/roles/all.ts. - GraphQL alias batching lets a single unauthenticated request repeat the same field under many aliases. Each aliased list field resolves to an independent database query, so an unbounded query can amplify one HTTP request into thousands of database operations (advisory
GHSA-732q-p8qr-4mcg).
Mitigations the integrator should apply:
-
In-process query validation. GraphQL Yoga
pluginsandvalidationRulesare forwarded verbatim throughstartPlatform/startAPIServerinto the underlying Yoga instance (packages/api/src/createGraphQLServer.ts). Wire in alias, depth, and token/cost limits — e.g. GraphQL Armor:import { startPlatform } from '@unchainedshop/platform'; import { maxAliasesPlugin } from '@escape.tech/graphql-armor-max-aliases'; import { maxDepthPlugin } from '@escape.tech/graphql-armor-max-depth'; import { maxTokensPlugin } from '@escape.tech/graphql-armor-max-tokens'; await startPlatform({ // ...your options plugins: [ maxAliasesPlugin({ n: 15 }), maxDepthPlugin({ n: 10 }), maxTokensPlugin({ n: 1000 }), ], });
Build the
pluginsarray explicitly; if you merge caller-supplied plugins, append the security plugins last so they cannot be silently overridden. -
Rate limiting & body-size limits at the edge. Apply per-IP / per-token rate limits and request-body-size caps at the reverse proxy, API gateway, or WAF in front of the engine. Give anonymous traffic a tighter budget than authenticated traffic.
-
Pagination clamping. Clamp
limit/ offset in any custom list resolvers you add, and consider restricting anonymous access to list queries if your storefront does not require it.
Unchained emits audit events using the OCSF (Open Cybersecurity Schema Framework) schema. Events can be collected from application logs or forwarded over OTLP/HTTP to a compatible collector.
The engine does not persist audit events itself — retention, queries, and integrity guarantees are the consuming log pipeline's or SIEM's concern.
- OCSF-based schema - Schema-conformant OCSF v1.4.0 events with e-commerce activity names
- Structured log emission - Enabled by default; set
UNCHAINED_LOG_FORMAT=jsonto write JSON lines - OTLP push - Optional OTLP/HTTP push to OpenTelemetry Collector, Vector, Fluent Bit, or vendor intakes
- Event integration - Automatic capture of authentication, orders, and payments
- E-commerce specific - Checkout, payment, refund, and access denied events
Audit logging is enabled by default in startPlatform(); OTLP push is opt-in:
import { startPlatform } from '@unchainedshop/platform';
const platform = await startPlatform({
auditLog: {
collectorUrl: 'http://otel-collector:4318/v1/logs', // optional OTLP push
},
});For custom audit events, use the singleton instance:
import {
getAuditLogInstance,
getAuditContext,
OCSF_AUTH_ACTIVITY,
OCSF_ACCOUNT_ACTIVITY,
OCSF_API_ACTIVITY,
} from '@unchainedshop/events';
const auditLog = getAuditLogInstance();
const requestContext = getAuditContext();
// Log authentication event
await auditLog?.logAuthentication({
activity: OCSF_AUTH_ACTIVITY.LOGON,
userId: user._id,
userName: user.username,
success: true,
remoteAddress: requestContext?.remoteAddress,
sessionId: requestContext?.sessionId,
isMfa: true,
});
// Log failed login attempt
await auditLog?.logAuthentication({
activity: OCSF_AUTH_ACTIVITY.LOGON,
userId: user._id,
success: false,
remoteAddress: requestContext?.remoteAddress,
message: 'Invalid password',
});
// Log account change event
await auditLog?.logAccountChange({
activity: OCSF_ACCOUNT_ACTIVITY.ATTACH_POLICY, // Role change
userId: targetUser._id,
actorUserId: adminUser._id, // Who made the change
success: true,
});
// Log user creation
await auditLog?.logAccountChange({
activity: OCSF_ACCOUNT_ACTIVITY.CREATE,
userId: newUser._id,
userName: newUser.username,
success: true,
});
// Log API activity (payments, orders, etc.)
await auditLog?.logApiActivity({
activity: OCSF_API_ACTIVITY.PAYMENT,
userId: user._id,
operation: 'processPayment',
success: true,
remoteAddress: requestContext?.remoteAddress,
message: 'Payment completed',
});
// Log access denied
await auditLog?.logApiActivity({
activity: OCSF_API_ACTIVITY.ACCESS_DENIED,
userId: user._id,
success: false,
remoteAddress: requestContext?.remoteAddress,
message: 'Access denied',
});startPlatform() wires the integration layer automatically. The configured event transport must support subscriptions (the base preset supplies the Node.js emitter). See AUDITED_EVENTS for the complete list, including:
API_LOGIN_TOKEN_CREATED→ Authentication (LOGON)API_LOGIN_FAILED→ Authentication (LOGON, failure)API_LOGOUT→ Authentication (LOGOFF)USER_CREATE→ Account Change (CREATE)USER_REMOVE→ Account Change (DELETE)USER_UPDATE_PASSWORD→ Account Change (PASSWORD_CHANGE)USER_ADD_ROLES→ Account Change (ATTACH_POLICY)ORDER_CREATE→ API Activity (CREATE)ORDER_CHECKOUT→ API Activity (CHECKOUT)ORDER_ADD_PRODUCT→ API Activity (UPDATE)ORDER_PAY→ API Activity (PAYMENT)- And more...
Shutdown (flushing pending collector batches) is handled by the platform's cleanup path.
| Class | UID | Use Cases |
|---|---|---|
| Authentication | 3002 | Login, logout, failed login, MFA |
| Account Change | 3001 | User CRUD, password changes, role changes |
| API Activity | 6003 | API access, payments, orders, access denied |
With UNCHAINED_LOG_FORMAT=json, every audit event is emitted as one JSON line on stdout via the unchained:audit logger, with the full OCSF event under the ocsf key:
{"timestamp":"2026-09-01T09:12:00.000Z","level":"INFO","name":"unchained:audit","message":"User Login","ocsf":{"class_uid":3002,"category_uid":3,"type_uid":300201,"activity_id":1,"severity_id":1,"time":1788253920000,"user":{"uid":"user-123","name":"john@example.com"},"src_endpoint":{"ip":"192.168.1.1"},"status_id":1,"is_mfa":true,"metadata":{"version":"1.4.0","product":{"name":"Unchained Engine"}}}}Authentication Activities (OCSF_AUTH_ACTIVITY):
| Activity | ID | Use Case |
|---|---|---|
LOGON |
1 | User login (success or failure) |
LOGOFF |
2 | User logout |
OTHER |
99 | Other authentication events |
Account Change Activities (OCSF_ACCOUNT_ACTIVITY):
| Activity | ID | Use Case |
|---|---|---|
CREATE |
1 | User creation |
PASSWORD_CHANGE |
3 | Password changed by user |
PASSWORD_RESET |
4 | Password reset by admin |
DELETE |
6 | User deletion |
ATTACH_POLICY |
7 | Role/permission changes |
MFA_ENABLE |
10 | MFA enabled |
MFA_DISABLE |
11 | MFA disabled |
OTHER |
99 | Other account changes |
API Activity Types (OCSF_API_ACTIVITY):
| Activity | ID | Use Case |
|---|---|---|
CREATE |
1 | Create operations |
READ |
2 | Read operations |
UPDATE |
3 | Update operations |
DELETE |
4 | Delete operations |
CHECKOUT |
90 | Order checkout (e-commerce extension) |
PAYMENT |
91 | Payment processing (e-commerce extension) |
REFUND |
92 | Refund processing (e-commerce extension) |
EXPORT |
93 | Data export (GDPR extension) |
IMPORT |
94 | Data import (extension) |
ACCESS_DENIED |
95 | Authorization failure (extension) |
OTHER |
99 | Other API activities |
Note: IDs 90-95 are internal Unchained identifiers. Standard OCSF defines activity_id 0-4 and 99 for API Activity, so emitted events carry activity_id: 99 (Other) with the specific label in the standard activity_name attribute (e.g. "Checkout") — fully schema-conformant, with fine-grained semantics also available in api.operation.
| Level | ID | Use Case |
|---|---|---|
| INFORMATIONAL | 1 | Normal operations (default for success) |
| LOW | 2 | Minor issues |
| MEDIUM | 3 | Standard operations |
| HIGH | 4 | Security-relevant events (default for failures) |
| CRITICAL | 5 | Critical security events |
| FATAL | 6 | System failures |
Two transport options are available; configure the consuming collector or SIEM to map the OCSF payload as needed:
1. Scrape stdout — run the engine with UNCHAINED_LOG_FORMAT=json and let a log agent tail the container output. Example OpenTelemetry Collector configuration:
receivers:
filelog:
include: [/var/log/containers/unchained-*.log]
operators:
- type: json_parser
- type: filter
expr: 'attributes.name != "unchained:audit"'
exporters:
elasticsearch:
endpoints: ["https://es:9200"]
service:
pipelines:
logs:
receivers: [filelog]
exporters: [elasticsearch]Vector, Fluent Bit, Promtail/Alloy, Filebeat and vendor agents work the same way: parse the JSON line and route on name == "unchained:audit".
2. OTLP push — the engine pushes OTLP/HTTP log records directly to a collector:
receivers:
otlp:
protocols:
http:
endpoint: 0.0.0.0:4318
exporters:
elasticsearch:
endpoints: ["https://es:9200"]
service:
pipelines:
logs:
receivers: [otlp]
exporters: [elasticsearch]The audit log is configured via startPlatform({ auditLog: {...} }); the OTLP push also honors the standard OpenTelemetry environment variables:
OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4318 # /v1/logs is appended
OTEL_EXPORTER_OTLP_LOGS_ENDPOINT=... # used verbatim, wins over the above
OTEL_EXPORTER_OTLP_HEADERS="Authorization=Bearer%20abc" # key=value,key=value
OTEL_SERVICE_NAME=my-shop # service.name resource attribute| Option | Default | Description |
|---|---|---|
log |
true |
Emit each event as a structured log line |
collectorUrl |
OTEL_EXPORTER_OTLP_* env |
OTLP/HTTP logs endpoint |
collectorHeaders |
env headers | HTTP headers for collector auth |
batchSize |
10 |
Events batched before pushing |
flushIntervalMs |
5000 |
Max interval between pushes |
maxQueueSize |
1000 |
Push queue cap (oldest dropped beyond it) |
In addition to audit events, Unchained emits transient events for real-time processing:
USER_CREATE,USER_UPDATE,USER_REMOVEUSER_UPDATE_PASSWORD,USER_ADD_ROLESUSER_ACCOUNT_ACTION(reset-password, verify-email, enroll-account)
import { emit } from '@unchainedshop/events';
// The default event history stores events in MongoDB with a configurable TTL
await emit('USER_UPDATE_PASSWORD', { user });The event-history TTL defaults to two days and is controlled by EVENTS_TTL_SECONDS. Audit logs are separate and use the retention configured in the consuming log pipeline.
Apply coarse request limits at the reverse proxy and operation-aware limits in the application or GraphQL gateway. Queries and mutations can both use HTTP POST, so the HTTP method alone cannot identify login or other sensitive operations.
nginx example:
# Define a coarse API request limit
limit_req_zone $binary_remote_addr zone=api:10m rate=100r/s;
server {
# Apply this limit to all GraphQL requests
location /graphql {
limit_req zone=api burst=50 nodelay;
proxy_pass http://unchained:4010;
}
}Cloudflare:
- Use Rate Limiting Rules for
/graphqlendpoint - Configure Bot Fight Mode
- Enable Under Attack Mode for emergencies
AWS ALB:
- Configure WAF rate-based rules
- Set thresholds per IP address
| Endpoint | Recommended Limit | Rationale |
|---|---|---|
| Login mutations | 5/minute per IP | Prevent brute force |
| Password reset | 3/hour per IP | Prevent enumeration |
| Registration | 10/hour per IP | Prevent spam |
| GraphQL queries | 100/second per IP | General protection |
| Bulk import | 1/minute per IP | Resource intensive |
- Set
UNCHAINED_TOKEN_SECRETto a strong, unique value (32+ chars) - Enable HTTPS/TLS termination
- Configure MongoDB with authentication and TLS
- Configure rate limiting at reverse proxy (nginx, Cloudflare, ALB)
- Configure
startPlatform({ auditLog: { ... } })and retention in the consuming log pipeline or SIEM - Configure monitoring and alerting
- Set up log aggregation for audit logs
- Regular security updates for dependencies
# Required
UNCHAINED_TOKEN_SECRET=<strong-random-value-32+chars>
NODE_ENV=production
# Recommended
UNCHAINED_COOKIE_SAMESITE=strict
UNCHAINED_COOKIE_DOMAIN=.yourdomain.com
# For FIPS mode
OPENSSL_CONF=/etc/ssl/openssl-fips.cnf- TLS 1.2+: Enforce modern TLS versions
- HSTS: Enable HTTP Strict Transport Security
- CSP: Configure Content Security Policy for admin UI
- CORS: Restrict allowed origins
# Connection with authentication and TLS
MONGO_URL=mongodb://user:pass@host:27017/unchained?tls=true&authSource=adminRun regular dependency audits:
npm audit
npm audit fixThe root manifest declares @mongodb-js/zstd in trustedDependencies and lists approved install scripts separately in allowScripts. These are package-manager-specific settings; check the manifest and package manager used by your deployment:
{
"trustedDependencies": ["@mongodb-js/zstd"]
}