Skip to content
Ownfold
Esc
navigateopen⌘Jpreview
On this page

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.

Last updated on August 4, 2026

Was this page helpful?