Security Model
Adopter-facing security documentation for Porulle. This document describes what the framework defends against, how it is configured, and what it does not cover.
Threat model
Section titled “Threat model”The framework defends against:
- OWASP Top 10 (injection, broken auth, sensitive data exposure, XXE, broken access control, misconfig, XSS, deserialization, known-vuln components, logging gaps)
- OWASP Business Logic Top 10 (mass assignment, race conditions, IDOR, privilege escalation)
- Commerce-specific severity classes: cross-tenant data leak, cross-customer IDOR, payment over-refund, inventory double-release, order status race, webhook forgery
The framework does not defend against:
- Magecart at adopter checkout pages. Requires CSP configuration by the adopter. The framework provides the hook (
config.security.csp); the adopter must use it. - Data residency. Single-region database today. No per-org region routing. Multi-region data sovereignty is Phase 2.
- Agent identity verification. The
Actortype has no agent principal. API keys are the only non-human identity. Agent attestation (Web Bot Auth, KYA) is Phase 2.
Org resolution profiles
Section titled “Org resolution profiles”The framework supports two tenant resolution modes.
B2C single-storefront
Section titled “B2C single-storefront”auth: { defaultOrganizationId: "org_default",}Customers without explicit org membership fall through to the default org. All data is scoped to one tenant. Used in apps/store-example/commerce.config.ts. This is the right mode for single-store deployments.
B2B multi-tenant
Section titled “B2B multi-tenant”auth: { strictOrgResolution: true, storeResolver: async (request) => { const storeId = request.headers.get("x-store-id"); return storeId ?? null; },}No fallback. Customers must be members of an org via Better Auth’s organization plugin. The storeResolver callback maps incoming requests to org IDs (header-based, domain-based, or path-based). If resolution fails and strictOrgResolution is true, the request is rejected with HTTP 503.
Production B2B deployments must configure storeResolver. Without it, all requests fall into the same default org and cross-tenant isolation breaks.
Reference: packages/core/src/auth/org.ts.
Rate limit layers
Section titled “Rate limit layers”Four rate limiters are applied in sequence. All are per-IP unless noted. Defaults can be overridden via config.rateLimits.
| Layer | Scope | Default | Config key |
|---|---|---|---|
/api/auth/* | Per-IP | 10/min | rateLimits.auth |
/api/auth/sign-in/email | Per-email (SHA-256 keyed) | 10/15min | rateLimits.signInPerEmail |
/api/checkout | Per-IP | 5/min | rateLimits.checkout |
/api/* | Per-IP | 100/min | rateLimits.api |
The per-email limiter hashes the email with SHA-256 before using it as the rate limit key, so raw emails are not stored in the rate limit state. Reference: packages/core/src/runtime/server.ts:47--51.
Limitation: Rate limit storage is in-memory. Limits are per-process and do not hold across multiple instances. For multi-instance deployments (ECS, Cloud Run, multiple Fly machines), a RateLimitStoreAdapter backed by Redis or Durable Objects is needed. This is not yet implemented. See agent-native audit Gap F4.
Cookie hygiene
Section titled “Cookie hygiene”Session cookies are configured with:
__Secure-prefix in production (viauseSecureCookies: truewhenNODE_ENV === "production")HttpOnly(managed by Better Auth)Secureflag in productionSameSite: lax— blocks CSRF on POST/PUT/DELETE while allowing top-level GET navigation (needed for OAuth redirects)
Reference: packages/core/src/auth/setup.ts:157--166.
CSP recommendations
Section titled “CSP recommendations”The framework exposes config.security.csp for Content-Security-Policy header injection.
security: { csp: { default: "default-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'", perRoute: { "/api/checkout": "default-src 'self'; script-src 'self' https://js.stripe.com; frame-src https://js.stripe.com https://hooks.stripe.com; object-src 'none'; base-uri 'self'; frame-ancestors 'none'", }, },},The /api/checkout route should have a strict CSP when integrating Stripe Elements or Braintree Hosted Fields. The policy above allows only the provider’s script and frame origins. Adjust for your payment provider.
Reference: packages/core/src/runtime/server.ts:252--265.
Trusted origins and CSRF
Section titled “Trusted origins and CSRF”config.auth.trustedOrigins sets the allowed origins for CORS and CSRF protection. In development, http://localhost:* is allowed by default. In production, an empty trustedOrigins array blocks all cross-origin requests.
CSRF middleware is scoped to /api/* via Hono’s csrf() middleware. Better Auth handles CSRF on /api/auth/* routes separately.
Reference: packages/core/src/runtime/server.ts:168--184.
Body limit
Section titled “Body limit”1 MB default via Hono’s bodyLimit middleware. Applied globally.
app.use("*", bodyLimit({ maxSize: 1024 * 1024, onError: (c) => c.json({ error: { code: "PAYLOAD_TOO_LARGE", message: "Request body exceeds 1MB limit." } }, 413),}));Override for media uploads is not yet a first-class config option. The media module has config.media.allowedMimeTypes and config.media.allowSvg for MIME validation, but no separate body size override.
Reference: packages/core/src/runtime/server.ts:187--192.
SSRF guards
Section titled “SSRF guards”Webhook URL registration validates against private, loopback, and metadata IPs:
- Loopback:
127.x.x.x,::1,localhost - Link-local:
169.254.x.x,fe80: - Private (RFC 1918):
10.x,172.16--31.x,192.168.x - Cloud metadata:
169.254.169.254,metadata.google.internal - Invalid URLs are blocked (returns
true)
Reference: packages/core/src/modules/webhooks/service.ts:13--48 (isPrivateUrl).
Audit log
Section titled “Audit log”Every mutation writes a row to commerce_audit_log with organizationId, entityType, entityId, event, payload, actorId, actorType, and requestId. Audit entries are written automatically via audit hooks registered during kernel boot (RFC-005). Services do not call audit.record() directly.
Schema: packages/core/src/modules/audit/schema.ts. Hook registration: packages/core/src/modules/audit/hooks.ts.
What is coming in Phase 2
Section titled “What is coming in Phase 2”These are roadmap items, not current capabilities:
- Agent principal model.
Actorwill be replaced with aPrincipaldiscriminated union supportingUser | ApiKey | BuyerAgent | SellerAgent | System. Authorization grants with scope, expiry, and amount caps. - UCP/ACP protocols. Beyond MCP. A
/.well-known/commerce-capabilitiesmanifest. Multi-protocol gateway. - Conversation layer.
Conversationentity withChannelAdapterfor WhatsApp/SMS/web-chat. - Returns-as-entity. Returns promoted from marketplace-plugin-local to a core entity with its own state machine.
- Data residency. Per-org region routing in the database adapter. Data classification tags. Cross-border transfer audit log.
- Rate limit store adapter. External store (Redis, Durable Objects) for cross-instance rate limiting.
Reference: .audits/agent-native-audit-unified-commerce-engine-2026-05-10.md, Migration Path.
Known gaps
Section titled “Known gaps”These are documented limitations from the security audit (commit 5d18ce6 and follow-up closures):
-
Coupon race condition. The promotion apply endpoint has not been load-tested at the database level. A unique index on
(promotion_id, customer_id, order_id)and a parallel-apply regression test are needed. Reference:SECURITY-AUDIT-V2-SYNTHESIS.md. -
Outbound webhook signature replay. The inbound side has
processed_webhook_eventsfor replay protection. The outbound side signs payloads but does not track delivery receipts. Replay protection is the receiver’s responsibility. Document this in your webhook consumer. -
HTTP request smuggling. Not tested at the proxy boundary. Fly.io fronts the deployment with their own proxy. If you deploy behind a different reverse proxy, test for request smuggling with
smuggleror equivalent. -
In-memory rate limiting. Does not hold across multiple instances. See Rate limit layers above.
-
requireEmailVerification: falsein production. The framework logs a warning at boot. If you run with email verification disabled in production, anyone can sign up with any email and access the account immediately. Enable it and configureconfig.email.sendfor production deployments.