Encrypt application records
Copyable browser and server recipes for storing notes, messages, documents, or health records as authenticated ciphertext.
Use encryptJson before sending a private value to your server. Store the returned envelope. Use
decryptJson only after reading it back into an unlocked Ownfold client.
Complete browser flow
import { vault } from "./vault-client"
export interface PrivateNote {
readonly title: string
readonly body: string
}
export const savePrivateNote = async (
noteId: string,
ownerId: string,
note: PrivateNote,
): Promise<void> => {
const encrypted = await vault.encryptJson({
namespace: "notes",
recordId: noteId,
ownerId,
value: note,
})
if (encrypted.status === "error") throw encrypted.error
const response = await fetch(`/api/notes/${encodeURIComponent(noteId)}`, {
method: "PUT",
headers: { "content-type": "application/json" },
body: JSON.stringify({ encryptedPayload: encrypted.value }),
})
if (!response.ok) throw new Error(`The encrypted note was not saved (${response.status}).`)
}
export const loadPrivateNote = async (
noteId: string,
ownerId: string,
): Promise<PrivateNote> => {
const response = await fetch(`/api/notes/${encodeURIComponent(noteId)}`)
if (!response.ok) throw new Error(`The encrypted note was not loaded (${response.status}).`)
const stored: unknown = await response.json()
const decrypted = await vault.decryptJson({
namespace: "notes",
recordId: noteId,
ownerId,
payload: stored,
})
if (decrypted.status === "error") throw decrypted.error
const value = decrypted.value
if (
typeof value !== "object" ||
value === null ||
Array.isArray(value) ||
typeof Reflect.get(value, "title") !== "string" ||
typeof Reflect.get(value, "body") !== "string"
) {
throw new Error("The decrypted note does not match the expected record shape.")
}
return { title: Reflect.get(value, "title"), body: Reflect.get(value, "body") }
}
namespace, recordId, and ownerId are authenticated encryption context. Use exactly the same
values for encryption, server validation, and decryption. Moving an envelope to another record or
user makes decryption fail.
Complete server write
This Next.js route authenticates the caller, bounds the JSON body, validates that it contains only the expected encrypted envelope, and stores no plaintext.
import { readBoundedJsonRequest } from "@ownfold/fetch"
import { pool } from "@/lib/database"
import { getSession } from "@/lib/session"
import { vaultServer } from "@/lib/vault-server"
export const PUT = async (
request: Request,
context: { params: Promise<{ noteId: string }> },
): Promise<Response> => {
const session = await getSession(request)
if (session === null) return Response.json({ error: "Unauthorized" }, { status: 401 })
const { noteId } = await context.params
const body = await readBoundedJsonRequest(request, { maxBodyBytes: 256 * 1024 })
if (body.status === "error") {
return Response.json({ error: body.error.message }, { status: 400 })
}
const validated = await vaultServer.validateEncryptedRecordWrite({
request,
body: body.value,
namespace: "notes",
recordId: noteId,
})
if (validated.status === "error") {
return Response.json(
{ error: validated.error.message, code: validated.error.code },
{ status: 400 },
)
}
await pool.query(
`INSERT INTO private_notes (user_id, id, encrypted_payload, created_at, updated_at)
VALUES ($1, $2, $3, now(), now())
ON CONFLICT (user_id, id) DO UPDATE
SET encrypted_payload = EXCLUDED.encrypted_payload, updated_at = now()`,
[session.user.id, noteId, validated.value.encryptedPayload],
)
return new Response(null, { status: 204 })
}
The session loader used here must return the same immutable user ID as the getUserId resolver in
VaultServer. Do not accept an owner ID from the request body.
Complete server read
The read route scopes the query to the authenticated owner and returns only the opaque envelope.
export const GET = async (
request: Request,
context: { params: Promise<{ noteId: string }> },
): Promise<Response> => {
const session = await getSession(request)
if (session === null) return Response.json({ error: "Unauthorized" }, { status: 401 })
const { noteId } = await context.params
const result = await pool.query<{ encrypted_payload: unknown }>(
`SELECT encrypted_payload
FROM private_notes
WHERE user_id = $1 AND id = $2`,
[session.user.id, noteId],
)
const row = result.rows[0]
return row === undefined
? new Response(null, { status: 404 })
: Response.json(row.encrypted_payload)
}
Choose a record table
CREATE TABLE private_notes (
user_id text NOT NULL,
id text NOT NULL,
encrypted_payload jsonb NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (user_id, id)
);CREATE TABLE private_notes (
user_id TEXT NOT NULL,
id TEXT NOT NULL,
encrypted_payload TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
PRIMARY KEY (user_id, id)
) STRICT;Serialize the envelope with JSON.stringify on write and parse it as untrusted JSON on read.
import type { EncryptedEnvelopeV1 } from "@ownfold/core"
import { jsonb, pgTable, primaryKey, text, timestamp } from "drizzle-orm/pg-core"
export const privateNotes = pgTable("private_notes", {
userId: text("user_id").notNull(),
id: text("id").notNull(),
encryptedPayload: jsonb("encrypted_payload").$type<EncryptedEnvelopeV1>().notNull(),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
}, (table) => [primaryKey({ columns: [table.userId, table.id] })])model PrivateNote {
userId String @map("user_id")
id String
encryptedPayload Json @map("encrypted_payload")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@id([userId, id])
@@map("private_notes")
}Do not add a plaintext title, preview, search column, analytics label, or debug copy. If a field is private, keep it inside the encrypted JSON value.
Test the boundary
const marker = "THIS MUST NEVER REACH THE SERVER"
const encrypted = await vault.encryptJson({
namespace: "notes",
recordId: "note-1",
ownerId: "user-1",
value: { title: marker, body: marker },
})
if (encrypted.status === "error") throw encrypted.error
expect(JSON.stringify({ encryptedPayload: encrypted.value })).not.toContain(marker)
Also test modified ciphertext, the wrong owner, replay to another record ID, truncated JSON, a locked client, and unsupported envelope versions. Every case must fail closed; never replace a decryption error with plaintext or empty content.
Visible metadata
The server still observes record existence, owner account, timestamps, request timing, record count, and approximate ciphertext size. Padding and encrypted search are not part of this release.