|
| 1 | +--- |
| 2 | +title: Encrypted Dumps |
| 3 | +description: Encrypted Dumps in Nuxt Content allow you to serve content safely on a public CDN while requiring authentication to access it. |
| 4 | +--- |
| 5 | + |
| 6 | +Encrypted Dumps in Nuxt Content allow you to serve your content safely on a public CDN (e.g. Cloudflare Pages) without exposing the raw `.sql` database files. |
| 7 | +Instead, dumps are encrypted at build time and decrypted in the browser only after the user has authenticated and received a short-lived key. |
| 8 | + |
| 9 | +They are especially useful for: |
| 10 | + |
| 11 | +- Hosting private content on static/CDN deployments |
| 12 | +- Keeping v3’s fast client-side SQLite queries without leaking raw dumps |
| 13 | +- Adding fine-grained access control using your own authentication |
| 14 | + |
| 15 | +## How it works |
| 16 | + |
| 17 | +1. **Build** – Each collection is compressed and encrypted with AES-256-GCM. |
| 18 | +2. **Static hosting** – Only encrypted `.enc` files are published (`dump.<collection>.sql.enc`). |
| 19 | +3. **Key request** – The client requests a short-lived key from `/__nuxt_content/:collection/key`, passing the `kid` extracted from the encrypted dump envelope. |
| 20 | +4. **Decrypt & hydrate** – The browser decrypts the dump in memory and hydrates its WASM SQLite database. |
| 21 | + |
| 22 | +Without the key, the dumps are useless. |
| 23 | + |
| 24 | +## Static files produced |
| 25 | + |
| 26 | +When `encryption.enabled = true`: |
| 27 | + |
| 28 | +- ✅ `dump.<collection>.sql.enc` → Encrypted database dump, safe to host on CDN. |
| 29 | +- ✅ `database/queries/*.sql` → Still generated internally, but not exposed publicly. |
| 30 | +- ❌ No `.sql` or `.txt` raw dumps are emitted to `public/` or `_nuxt/`. |
| 31 | + |
| 32 | +When `encryption.enabled = false` (default): |
| 33 | + |
| 34 | +- Raw `.sql` or `.txt` dumps are emitted and directly fetched by the client (plain-text behavior). |
| 35 | + |
| 36 | + |
| 37 | +## API endpoints |
| 38 | + |
| 39 | +Nuxt Content automatically provides endpoints for both **encrypted** and **unencrypted** modes. |
| 40 | + |
| 41 | +### 1. Encrypted mode |
| 42 | + |
| 43 | +- `GET /__nuxt_content/:collection/sql_dump.enc` |
| 44 | + Returns the encrypted dump envelope (stringified JSON, base64). |
| 45 | + Safe to cache on a CDN. |
| 46 | + |
| 47 | +- `GET /__nuxt_content/:collection/key?kid=<kid>` |
| 48 | + Returns `{ kid, k }` where `k` is the short-lived base64-encoded AES key. The `kid` comes from the dump’s envelope and ensures the key matches the actual dump version, even if the SPA is stale. |
| 49 | + Must be protected with **your authentication middleware**. |
| 50 | + This endpoint is the only place the actual key is exposed. |
| 51 | + |
| 52 | +### 2. Plaintext (no encryption) |
| 53 | + |
| 54 | +- `GET /__nuxt_content/:collection/sql_dump.txt` |
| 55 | + Returns the raw compressed SQL array (unsafe for private data). |
| 56 | + Still available when `encryption.enabled = false`. |
| 57 | + |
| 58 | +- `POST /__nuxt_content/:collection/query` |
| 59 | + Runs an SQL query against the collection database. |
| 60 | + Used internally by the client after the dump is hydrated. |
| 61 | + |
| 62 | +## Offline access |
| 63 | + |
| 64 | +When a dump has been decrypted once, the client can cache the derived key locally (keyed by `kid`). On subsequent loads, the cached key is tried first to allow reading content while offline. If it fails (e.g. after a redeploy with a new checksum), the client discards it and requests a fresh key. |
| 65 | + |
| 66 | +## Enable encryption |
| 67 | + |
| 68 | +```ts [nuxt.config.ts] |
| 69 | +export default defineNuxtConfig({ |
| 70 | + content: { |
| 71 | + encryption: { |
| 72 | + enabled: true, |
| 73 | + masterKey: process.env.NUXT_CONTENT_MASTER_KEY, // base64(32 bytes) |
| 74 | + } |
| 75 | + } |
| 76 | +}) |
| 77 | +``` |
| 78 | + |
| 79 | +If `masterKey` isn't provided, Nuxt Content generates a random 32-byte key at build time and keeps it on the server. |
| 80 | + |
| 81 | +Generate a master key: |
| 82 | + |
| 83 | +```bash |
| 84 | +openssl rand -base64 32 |
| 85 | +# or |
| 86 | +node -e "console.log(require('crypto').randomBytes(32).toString('base64'))" |
| 87 | +``` |
| 88 | + |
| 89 | +## Authentication middleware |
| 90 | + |
| 91 | +You must protect the **key endpoint** and the `__nuxt_content` so only authenticated users receive decryption keys and content: |
| 92 | + |
| 93 | +``` |
| 94 | +// server/middleware/content-auth.ts |
| 95 | +import { defineEventHandler, createError, getRequestURL } from 'h3' |
| 96 | +
|
| 97 | +// --- Provided elsewhere. Do NOT implement here. --- |
| 98 | +declare function getUser(event: any): any | null |
| 99 | +declare function hasAccess(user: any, collection: string): boolean |
| 100 | +declare function isAdmin(user: any): boolean |
| 101 | +// -------------------------------------------------- |
| 102 | +
|
| 103 | +// all collections prefixed course_ will be private each collection has a different key |
| 104 | +const PRIVATE_COLLECTION_PREFIXES: string[] = ['course_'] |
| 105 | +
|
| 106 | +function getCollectionFromPath(pathname: string): string | null { |
| 107 | + const m = pathname.match(/\/__nuxt_content\/([^/]+)/) |
| 108 | + return m ? m[1] : null |
| 109 | +} |
| 110 | +
|
| 111 | +function isKeyEndpoint(pathname: string): boolean { |
| 112 | + return /\/__nuxt_content\/[^/]+\/key\/?$/.test(pathname) |
| 113 | +} |
| 114 | +
|
| 115 | +function isPrivateCollection(collection: string): boolean { |
| 116 | + return PRIVATE_COLLECTION_PREFIXES.some(prefix => |
| 117 | + collection.startsWith(prefix) |
| 118 | + ) |
| 119 | +} |
| 120 | +
|
| 121 | +export default defineEventHandler(async (event) => { |
| 122 | + const url = getRequestURL(event).pathname |
| 123 | +
|
| 124 | + // Skip auth for prerender or build phases |
| 125 | + if ( |
| 126 | + process.env.NODE_ENV === 'prerender' || |
| 127 | + process.env.npm_lifecycle_event === 'build' |
| 128 | + ) { |
| 129 | + return |
| 130 | + } |
| 131 | +
|
| 132 | + // Handle admin endpoints |
| 133 | + if (url.includes('/api/admin')) { |
| 134 | + const user = getUser(event) |
| 135 | + if (!user || !isAdmin(user)) { |
| 136 | + throw createError({ |
| 137 | + statusCode: 403, |
| 138 | + statusMessage: 'Unauthorized', |
| 139 | + }) |
| 140 | + } |
| 141 | + return |
| 142 | + } |
| 143 | +
|
| 144 | + // Only protect __nuxt_content routes |
| 145 | + if (!url.includes('/__nuxt_content/')) return |
| 146 | +
|
| 147 | + const collection = getCollectionFromPath(url) |
| 148 | + const forKey = isKeyEndpoint(url) |
| 149 | +
|
| 150 | + if (!collection) { |
| 151 | + throw createError({ statusCode: 404, statusMessage: 'Not Found' }) |
| 152 | + } |
| 153 | +
|
| 154 | + // Public collections (e.g. "blog") are always allowed |
| 155 | + if (!isPrivateCollection(collection)) return |
| 156 | +
|
| 157 | + // Private collections: require user |
| 158 | + const user = getUser(event) |
| 159 | + if (!user) { |
| 160 | + throw createError({ |
| 161 | + statusCode: 401, |
| 162 | + statusMessage: 'Unauthorized', |
| 163 | + message: forKey |
| 164 | + ? 'Sign in to request a decryption key for this collection.' |
| 165 | + : 'Sign in to access this collection.', |
| 166 | + }) |
| 167 | + } |
| 168 | +
|
| 169 | + // Authorization check via provided helper |
| 170 | + if (!hasAccess(user, collection)) { |
| 171 | + throw createError({ |
| 172 | + statusCode: 403, |
| 173 | + statusMessage: 'Forbidden', |
| 174 | + message: forKey |
| 175 | + ? 'You do not have permission to obtain a key for this collection.' |
| 176 | + : 'You do not have permission to access this collection.', |
| 177 | + }) |
| 178 | + } |
| 179 | +
|
| 180 | + // If reached here: allowed |
| 181 | +}) |
| 182 | +``` |
| 183 | + |
| 184 | +- The /__nuxt_content/:collection/key endpoint is invoked after this middleware. |
| 185 | +- Because each collection (`course_*`) runs its own HKDF derivation, the API will hand out different decryption keys for different collections. |
| 186 | +- A client with the course key cannot decrypt the premium dump, and vice versa — the separation is enforced cryptographically. |
| 187 | + |
| 188 | +## Why encrypted dumps are secure |
| 189 | + |
| 190 | +This design uses HKDF (HMAC-based Key Derivation Function) to ensure strong separation between collections: |
| 191 | + |
| 192 | +- If you **don’t hand out a key** from `/__nuxt_content/:collection/key`, the client cannot decrypt that collection’s dump. |
| 193 | + The encrypted file on the CDN is useless without the key. |
| 194 | +- If you hand out a key for one collection (e.g. `posts`), the client can only decrypt that dump. |
| 195 | + They cannot derive or guess the key for another collection (e.g. `docs`) because: |
| 196 | + - The HKDF `info` parameter is different (`content:posts` vs `content:docs`). |
| 197 | + - The server never shares the **master key**. |
| 198 | +- Since the **kid** (which encodes the dump’s checksum) is included in the derivation, a new build with updated content produces a new key. Old keys won’t work with updated dumps. |
| 199 | + |
| 200 | +## Summary |
| 201 | + |
| 202 | +* Encrypted dumps are **safe static artifacts**. |
| 203 | +* API endpoints provide either the encrypted blob or a short-lived key. |
| 204 | +* Middleware is required to control who can fetch keys. |
| 205 | +* Clients transparently decrypt and hydrate, preserving v3’s offline & fast querying benefits. |
0 commit comments