Building Secure Apps
Every cPod App runs inside the CyberPod platform trust boundary. The platform vouches for the caller's identity — tenant, user, install, and permissions — by signing each request with an HMAC-SHA256 context header. Your app must verify that signature before reading data, check write permissions before mutating EDM records, and emit audit events for every write.
This guide covers the security primitives you will use when building apps on the platform. The reference implementation lives in shared/platform-runtime/ in the app-store repo; this page explains why each primitive exists and how to use it correctly.
Platform Context#
Platform context is the identity payload that the CyberPod platform attaches to every request your app receives. It carries:
| Field | Header | Description |
|---|---|---|
tenantId | x-cpod-tenant-id | The tenant the request belongs to |
userId | x-cpod-user-id | The authenticated user making the request |
installId | x-cpod-install-id | The specific app installation the request targets |
permissions | x-cpod-permissions | Comma-separated list of granted permissions |
In production, the platform also attaches a cryptographic signature and a set of time-bound claims:
| Claim | Header | Description |
|---|---|---|
| Signature | x-cpod-context-signature | HMAC-SHA256 hex digest of the canonical signing payload |
issuedAt | x-cpod-context-issued-at | Unix timestamp (seconds or milliseconds) when the context was issued |
expiresAt | x-cpod-context-expires-at | Unix timestamp when the context expires |
audience | x-cpod-context-audience | Must match your app's CPOD_CONTEXT_AUDIENCE or CPOD_APP_ID |
nonce | x-cpod-context-nonce | Replay-prevention nonce (minimum 12 characters) |
HMAC-SHA256 Verification#
The platform signs a canonical JSON payload built from the context fields and claims:
{
"tenantId": "...",
"userId": "...",
"installId": "...",
"permissions": ["edm.customer.read", "edm.customer.write"],
"issuedAt": "...",
"expiresAt": "...",
"audience": "...",
"nonce": "..."
}Your app verifies the signature using the shared CPOD_CONTEXT_SIGNING_SECRET. The comparison uses timingSafeEqual to prevent timing attacks.
Never compare signatures with ===. The platform runtime uses constant-time comparison to prevent timing side channels. Always delegate signature verification to getPlatformContext().
TTL, Audience, and Nonce Validation#
Signed contexts are validated against three temporal and identity constraints:
- TTL: The difference between
expiresAtandissuedAtmust not exceedCPOD_CONTEXT_MAX_TTL_SECONDS(default:300— five minutes). A clock skew ofCPOD_CONTEXT_CLOCK_SKEW_SECONDS(default:60) is tolerated. - Audience: The
audienceclaim must matchCPOD_CONTEXT_AUDIENCEorCPOD_APP_ID. In production, a missing audience configuration causes verification to fail. - Nonce: Must be present and at least 12 characters long.
Trust Modes#
getPlatformContext() returns a trustMode that tells you exactly why a context is or is not trusted:
trustMode | trusted | Meaning |
|---|---|---|
signed | true | HMAC signature is valid, TTL/audience/nonce all pass |
dev_unsigned | true | Local dev only — CPOD_TRUST_UNSIGNED_CONTEXT=true and request from localhost |
missing | false | No signature header present |
invalid | false | Signature mismatch, malformed claims, missing required fields, or TTL violation |
expired | false | expiresAt is in the past (accounting for clock skew) |
wrong_audience | false | Audience claim does not match your app's expected audience |
Usage#
import { getPlatformContext } from '../../../shared/platform-runtime'
const context = await getPlatformContext()
if (!context.trusted) {
// Render a clear error: context.trustMode tells you why
return { error: `Untrusted context: ${context.trustMode}` }
}
// context.tenantId, context.userId, context.installId, context.permissions
// are now safe to use for EDM reads and writesIn local development with CPOD_TRUST_UNSIGNED_CONTEXT=true, the runtime falls back to identity values from CPOD_DEV_TENANT_ID, CPOD_DEV_USER_ID, and CPOD_DEV_INSTALL_ID (defaults: tenant_dev, user_dev, install_dev). This mode only works on localhost and never in production.
Governed Writes#
All EDM mutations follow a prepare → review → commit lifecycle. The governed write pattern ensures that no write reaches the EDM without passing validation, permission checks, and — when required — human approval.
The Three Phases#
-
Prepare — The app constructs a draft and validates it locally. The write gate checks permissions and context trust. If anything fails, the result is
prepare_only: the draft is returned to the caller with validation errors, but nothing is written. -
Review — For high-risk mutations, the
commitflag is set tofalseand the draft is shown to a reviewer. The UI renders the diff, source citations, and reason before the user confirms. -
Commit — The app calls
applyEdmUpdatewithcommit: true. The write gate is re-checked, the SDK write is executed, and an audit event is emitted.
Input: AppGovernedUpdateInput#
| Field | Type | Required | Description |
|---|---|---|---|
domain | string | yes | EDM domain path (e.g. customer.accounts) |
recordId | string | yes | Fully qualified record ID (e.g. customer.accounts/abc-123) |
draft | object | yes | Key-value pairs to write — must contain at least one field |
commit | boolean | yes | true to execute the write, false to prepare only |
reason | string | yes | Human-readable purpose of the write (included in audit) |
approver | string | no | Identity of the approver for human-in-the-loop flows |
Context: AppGovernedUpdateContext#
| Field | Type | Description |
|---|---|---|
trusted | boolean | Whether the platform context is verified |
tenantId | string | Tenant scope for the write |
userId | string | Actor performing the write |
installId | string | App installation scope |
permissions | string[] | Granted permissions list |
Result: AppGovernedUpdateResult#
| Field | Type | Description |
|---|---|---|
writeMode | enum | commit — write executed; prepare_only — draft returned with errors; pending_approval — awaiting human review |
validation | object | errors (hard failures) and warnings (write gate issues) arrays |
diff | object | The draft that was (or would be) written |
audit | object | purpose, actor, tenantId, timestamp |
appliedAt | string | ISO 8601 timestamp (only present when writeMode is commit) |
Usage#
import { applyEdmUpdate } from '../../../shared/platform-runtime'
const result = await applyEdmUpdate(
{
domain: 'customer.accounts',
recordId: 'customer.accounts/abc-123',
draft: { status: 'active' },
commit: true,
reason: 'Account reactivated after payment received',
},
context,
)
if (result.writeMode === 'commit') {
// Write succeeded — result.appliedAt has the timestamp
} else {
// Write was prepared but not committed
// result.validation.errors explains why
// result.validation.warnings shows write gate issues
}applyEdmUpdate automatically determines whether to call create() or update() on the SDK service. If the recordId contains /new- or has no entity ID segment, it calls create(); otherwise it calls update(entityId, draft).
Write Gates#
Every governed write passes through validateWriteGate before reaching the SDK. The write gate is a permission and identity check — if it fails, the write is blocked and the result is prepare_only with the gate failures listed in validation.warnings.
What the Write Gate Checks#
The gate validates the following, in order:
- Trusted context —
context.trustedmust betrue. An unverified context cannot write to the EDM. - Tenant ID —
context.tenantIdmust be present. - User ID —
context.userIdmust be present. - Install ID —
context.installIdmust be present. - Domain permission — The context's
permissionsarray must contain the required write permission for the target domain.
Permission Map Pattern#
The required permission for a domain is resolved through a prefix-based permission map. Each app defines its own map in lib/edm.ts:
const permissionMap: Record<string, string> = {
'customer.': 'edm.customer.write',
'approvals.': 'edm.approvals.write',
'projects.': 'edm.projects.write',
'riskCompliance.': 'edm.riskCompliance.write',
}When a write targets customer.accounts, the gate looks up the longest matching prefix (customer.) and requires edm.customer.write in the caller's permissions.
If no prefix matches, the gate falls back to a default: edm.{rootDomain}.write. For storage.files, the default would be edm.storage.write.
Gate Failure Is Non-Destructive#
When the write gate fails:
- The draft is preserved in
result.diff— nothing is lost. result.writeModeisprepare_only.result.validation.warningslists every gate failure (e.g."Trusted or signed platform context is required for EDM writes.","edm.customer.write is required for EDM writes.").- No SDK call is made and no audit event is emitted.
This lets the UI render the draft for review, show exactly which permissions are missing, and offer the user a path to request access.
Audit Emission#
Every governed write — whether it succeeds or fails — emits an audit event through the CyberPod SDK telemetry service. Audit events give platform administrators a complete trail of who changed what, when, and why.
What Gets Emitted#
On a successful commit or a failed write attempt, applyEdmUpdate calls client.audit.emit() with:
| Field | Value |
|---|---|
action | edm.{domain}.write (e.g. edm.customer.accounts.write) |
resourceId | The recordId from the input |
resourceType | The domain from the input |
metadata.purpose | The reason from the input |
metadata.actor | context.userId, falling back to input.approver |
metadata.tenantId | context.tenantId |
metadata.timestamp | ISO 8601 UTC |
metadata.outcome | success or failure |
metadata.actorType | user if userId is present, otherwise system |
Non-Blocking#
Audit emission is non-blocking: if the client.audit.emit() call fails, the write result is not affected. The write still returns commit on success. This prevents audit infrastructure issues from causing data loss.
Audit emission only occurs when the write gate passes and an SDK call is attempted. If the write gate fails (missing permissions, untrusted context), no SDK call is made and no audit event is emitted. The gate failure is surfaced through result.validation.warnings instead.
MCP Tool Security#
MCP (Model Context Protocol) tools expose app functionality to AI agents. They must follow the same security model as UI routes — there is no separate trust boundary.
Same Auth as UI Routes#
Every MCP handler must verify platform context before doing any work:
// In your MCP route handler
const context = await getPlatformContext()
if (!context.trusted) {
return Response.json({
error: `Untrusted context: ${context.trustMode}`,
}, { status: 403 })
}MCP tools do not get a free pass on auth. An agent calling your tool has the same permissions as the user it acts on behalf of.
Input Validation with Strict Schemas#
Every MCP tool must declare an input schema and validate against it before processing:
- Define the expected parameters in your tool's
definitions.ts. - Validate types, required fields, enums, and string lengths in your handler.
- Reject requests that do not match the schema — do not coerce or fill in defaults silently.
Response Masking for Sensitive Fields#
MCP tool responses flow back to AI agents, which may surface them in chat, logs, or downstream tools. Mask sensitive fields before returning:
- PII (email, phone, SSN) — mask or redact based on tenant policy.
- Secrets, tokens, API keys — never include in responses.
- Internal IDs that have no user-facing meaning — omit unless needed for a follow-up action.
Use the CyberPod SDK masking service (sdk.masking) when available, or implement field-level masking in your app's service layer.
Human-in-the-Loop for Mutations#
MCP tools that write to the EDM must follow the governed write pattern:
- Prepare the draft and return it to the agent.
- Require explicit user confirmation before committing.
- Never auto-commit a mutation from an MCP tool without human approval.
// Step 1: Prepare only
const prepared = await applyEdmUpdate(
{ domain: 'customer.accounts', recordId: '...', draft: {...}, commit: false, reason: 'Agent requested status change' },
context,
)
// Step 2: Return diff to agent for user review
// Step 3: On explicit user confirmation, call again with commit: trueNever Expose Raw Access#
MCP tools must never:
- Expose raw SDK method calls (the agent should not call
client.customer.accounts.list()directly). - Pass through raw SQL or query builders.
- Allow unrestricted filesystem or storage browsing.
- Return app-private SQLite data, logs, cache, or temp files.
- Surface unmasked sensitive data from any source.
Wrap every capability in a purpose-built tool with a defined schema, permission check, and masked output.
Security Checklist#
Use this checklist before shipping any cPod App to production:
Context and Identity#
- Platform context is verified via
getPlatformContext()before any data access or EDM read. - Untrusted contexts (
context.trusted === false) produce a clear error with thetrustModereason. -
CPOD_CONTEXT_AUDIENCEorCPOD_APP_IDis configured in every non-dev environment. -
CPOD_CONTEXT_SIGNING_SECRETis set and matches the platform's signing secret.
Governed Writes#
- All EDM mutations go through
applyEdmUpdate()— no direct SDK write calls from components or handlers. - Write gates pass (
validation.warningsis empty) before any write is committed. - The
reasonfield on every write is specific and human-readable (not a generic string like"update"). - High-risk writes use
commit: falsefirst and require explicit user confirmation.
Audit and Observability#
- Audit events are emitted for all writes (automatic via
applyEdmUpdate). -
CPOD_API_KEYis configured in every non-dev environment so audit and write paths function.
MCP Tools#
- Every MCP tool handler verifies platform context before processing.
- Input is validated against a declared schema.
- Sensitive fields are masked in responses.
- Mutation tools use prepare-then-commit with human confirmation.
- No tool exposes raw SDK calls, raw storage, SQLite, or filesystem access.
Architecture#
- React components and UI code call
lib/services.ts— never raw SDK, storage, or SQLite directly. - The app's
lib/edm.tsdeclaresedmDomainsandpermissionMapexplicitly. - Demo and emulator data is visibly labeled in the UI (
source: 'demo_fixture'orsource: 'cyberpod-emulator'). - Demo records are never submitted into live write paths.