Skip to content

Commit 300b814

Browse files
committed
feat: sign og image urls by default
Previously runtime OG image URLs were only signed when a `security.secret` was configured; without one the `/_og/d/**` endpoint accepted arbitrary unsigned requests. Now a per-build secret is auto-generated when none is set, so URL signing is on by default with no configuration. - Resolve the signing secret via a pure `resolveSigningSecret()`: explicit opt-out (`secret: false`) > explicit config/env value > auto-generated. - The auto value is random, server-only, and baked into the build. It is stable within a build artifact (runtime sign + verify agree) but rotates per build, so an explicit secret is still recommended for rolling/multi-instance deploys — surfaced via a dev warning, gated on having a server runtime (no warning for pure SSG/static builds). - Only an explicit secret is baked into the `ogImage.secret` override channel, keeping the `NUXT_OG_IMAGE_SECRET` runtime override (e.g. Cloudflare env) reachable. The auto value lives solely in the build-time `security.secret`. - `strict` still requires an explicit secret (the auto value's per-build rotation isn't a strong enough guarantee). - `security.secret: false` disables signing entirely. Test fixtures that assert unsigned dynamic URLs (basic, cloudflare-satori, vercel-edge-satori) opt out via `secret: false`; signing remains covered by the url-signing unit tests and the cloudflare-runtime-config e2e.
1 parent 5aa6dbf commit 300b814

8 files changed

Lines changed: 161 additions & 25 deletions

File tree

docs/content/3.guides/13.security.md

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -44,11 +44,29 @@ npx nuxt-og-image generate-secret
4444

4545
## URL Signing
4646

47-
When you configure a signing secret, every OG image URL includes a cryptographic signature in the path. The server verifies this signature before rendering, rejecting any URL that has been tampered with or crafted manually.
47+
OG image URLs are signed by default. Every URL includes a cryptographic signature in the path, and the server rejects any runtime request whose signature is missing or invalid with a `403`. This prevents unauthorized image generation requests that would otherwise consume server resources.
4848

49-
This prevents unauthorized image generation requests that would otherwise consume server resources.
49+
### Default: auto-generated secret
5050

51-
### Setup
51+
When you do not configure a secret, the module generates a random one at build time, so signing works with no setup. The auto-generated secret **changes on every build**. That is fine for prerendered images (served as static files) and single-instance runtime deploys, but during a rolling or multi-instance deploy a URL signed by one build can fail verification on another.
52+
53+
For those deploys, set a stable secret so every instance shares the same value.
54+
55+
### Disabling signing
56+
57+
To serve unsigned runtime URLs (not recommended), set `secret` to `false`:
58+
59+
```ts [nuxt.config.ts]
60+
export default defineNuxtConfig({
61+
ogImage: {
62+
security: {
63+
secret: false,
64+
}
65+
}
66+
})
67+
```
68+
69+
### Setup (stable secret)
5270

5371
1. Generate a secret:
5472

@@ -76,7 +94,7 @@ export default defineNuxtConfig({
7694

7795
### How It Works
7896

79-
When you configure a secret:
97+
With signing active:
8098
- `defineOgImage()`{lang="ts"} appends a signature to the URL path: `/_og/d/w_1200,h_600,s_abc123def456.png`
8199
- The server extracts and verifies the signature before processing the request
82100
- Requests with missing or invalid signatures receive a `403` response

docs/content/4.api/3.config.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -197,7 +197,7 @@ See the [Browser Renderer](/docs/og-image/renderers/browser) guide for more deta
197197

198198
Security limits for image generation. See the [Security Guide](/docs/og-image/guides/security) for full details.
199199

200-
- **`secret`**: Signing secret for URL tamper protection. When set, all runtime OG image URLs are signed and unsigned requests are rejected with `403`. Generate one with `npx nuxt-og-image generate-secret`. See the [Security Guide](/docs/og-image/guides/security#url-signing) for details.
200+
- **`secret`**: Signing secret for URL tamper protection. Runtime OG image URLs are signed by default; unsigned requests are rejected with `403`. Leave unset to auto-generate a per-build secret, set an explicit stable string for rolling/multi-instance deploys, or `false` to disable signing. Generate one with `npx nuxt-og-image generate-secret`. See the [Security Guide](/docs/og-image/guides/security#url-signing) for details.
201201
- **`maxDimension`**: Maximum width or height in pixels. Default `2048`.
202202
- **`maxDpr`**: Maximum device pixel ratio (Takumi renderer). Default `2`.
203203
- **`renderTimeout`**: Milliseconds before the render is aborted with a `408` response. Default `15000`.

src/build/signing-secret.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
export interface SigningSecretResolution {
2+
/** Secret to bake into the runtime config (`''` when signing is disabled). */
3+
secret: string
4+
/** True when the user provided an explicit secret (config value or env var). */
5+
hasExplicit: boolean
6+
/** True when the user explicitly disabled signing via `security.secret: false`. */
7+
optOut: boolean
8+
/** True when `secret` was auto-generated (no explicit value, not opted out). */
9+
generated: boolean
10+
}
11+
12+
/**
13+
* Resolve the URL-signing secret at build time.
14+
*
15+
* Precedence: explicit opt-out (`secret: false`) → explicit config/env value →
16+
* auto-generated. Auto-generation turns signing on by default; the generated
17+
* value is random and server-only but rotates per build, so an explicit secret
18+
* is still recommended for rolling/multi-instance deploys.
19+
*
20+
* `generate` is injected so callers control the source (and tests stay
21+
* deterministic); production passes a CSPRNG.
22+
*/
23+
export function resolveSigningSecret(
24+
configSecret: string | false | undefined,
25+
envSecret: string | undefined,
26+
generate: () => string,
27+
): SigningSecretResolution {
28+
if (configSecret === false)
29+
return { secret: '', hasExplicit: false, optOut: true, generated: false }
30+
31+
const explicit = (configSecret || '') || (envSecret || '')
32+
if (explicit)
33+
return { secret: explicit, hasExplicit: true, optOut: false, generated: false }
34+
35+
return { secret: generate(), hasExplicit: false, optOut: false, generated: true }
36+
}

src/module.ts

Lines changed: 45 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import type {
1515
RuntimeCompatibilityMeta,
1616
RuntimeCompatibilitySchema,
1717
} from './runtime/types'
18+
import { randomBytes } from 'node:crypto'
1819
import * as fs from 'node:fs'
1920
import { existsSync } from 'node:fs'
2021
import { mkdir, readFile, writeFile } from 'node:fs/promises'
@@ -39,6 +40,7 @@ import {
3940
import { setupGenerateHandler } from './build/generate'
4041
import { setupPrerenderHandler } from './build/prerender'
4142
import { extractPropNamesFromVue, loadSfcCompiler } from './build/props'
43+
import { resolveSigningSecret } from './build/signing-secret'
4244
import { TreeShakeComposablesPlugin } from './build/tree-shake-plugin'
4345
import { AssetTransformPlugin } from './build/vite-asset-transform'
4446
import { ComponentImportRewritePlugin } from './build/vite-component-import-rewrite'
@@ -302,12 +304,14 @@ export interface ModuleOptions {
302304
* keyed hash signature and the handler rejects requests with missing or
303305
* invalid signatures.
304306
*
305-
* Must be a stable string across deployments. Generate one with:
307+
* Leave unset to auto-generate a per-build secret (signing on by default).
308+
* Set an explicit, stable string for rolling or multi-instance deploys, or
309+
* `false` to disable signing entirely. Generate one with:
306310
* `npx nuxt-og-image generate-secret`
307311
*
308-
* Required when `strict` is enabled.
312+
* Required (explicitly) when `strict` is enabled.
309313
*/
310-
secret?: string
314+
secret?: string | false
311315
/**
312316
* Enable strict security mode. When enabled:
313317
* - `secret` is required (URL signing)
@@ -421,25 +425,41 @@ export default defineNuxtModule<ModuleOptions>({
421425
logger.warn('`ogImage.debug` is enabled in production. This exposes the `/_og/debug.json` endpoint and should not be enabled in production. Disable it before deploying.')
422426
}
423427

424-
const hasSecret = !!(config.security?.secret || process.env.NUXT_OG_IMAGE_SECRET)
428+
// Resolve the URL-signing secret. No explicit secret → auto-generate one so
429+
// signing is on by default; the value is random, server-only, and baked into
430+
// the build (stable within a build artifact so runtime sign + verify agree).
431+
const signing = resolveSigningSecret(
432+
config.security?.secret,
433+
process.env.NUXT_OG_IMAGE_SECRET,
434+
() => randomBytes(32).toString('base64url'),
435+
)
436+
const resolvedSecret = signing.secret
425437

426-
if (config.security?.strict && !hasSecret) {
438+
// Strict still requires an explicit, stable secret — the auto value's
439+
// per-build rotation isn't a strong enough guarantee for strict mode.
440+
if (config.security?.strict && !signing.hasExplicit) {
427441
throw new Error('[nuxt-og-image] `security.strict` requires a signing secret. Generate one with: npx nuxt-og-image generate-secret')
428442
}
429443

430-
if (nuxt.options.dev && !config.zeroRuntime && !hasSecret) {
431-
logger.warn([
432-
'OG image URLs are not signed. Anyone can craft arbitrary image generation requests.',
433-
'',
434-
'Set a signing secret via env variable:',
435-
' NUXT_OG_IMAGE_SECRET=<secret>',
436-
'',
437-
' Generate one with: npx nuxt-og-image generate-secret',
438-
'',
439-
'Or enable zero-runtime mode to disable dynamic generation entirely:',
440-
' ogImage: { zeroRuntime: true }',
441-
].join('\n'))
442-
}
444+
// Signing only happens at runtime, so the secret warnings are irrelevant for
445+
// pure SSG/static deploys (no server, images served as files). Defer them to
446+
// nitro:init where `nitro.options.static` authoritatively reflects the preset.
447+
nuxt.hook('nitro:init', (nitro) => {
448+
const hasServerRuntime = !nitro.options.static && !(nuxt.options as any)._generate
449+
if (!nuxt.options.dev || config.zeroRuntime || !hasServerRuntime)
450+
return
451+
if (signing.optOut) {
452+
logger.warn('OG image URL signing is disabled (`security.secret: false`). Anyone can craft arbitrary image generation requests.')
453+
}
454+
else if (signing.generated) {
455+
logger.warn([
456+
'OG image URLs are signed with an auto-generated secret that changes every build.',
457+
'This is fine for single-instance deploys; for rolling or multi-instance deploys set a stable secret:',
458+
' NUXT_OG_IMAGE_SECRET=<secret>',
459+
' Generate one with: npx nuxt-og-image generate-secret',
460+
].join('\n'))
461+
}
462+
})
443463

444464
// Check for removed/deprecated config options
445465
const ogImageConfig = config as unknown as Record<string, unknown>
@@ -1629,7 +1649,7 @@ export const rootDir = ${JSON.stringify(nuxt.options.rootDir)}`
16291649
restrictRuntimeImagesToOrigin: config.security?.restrictRuntimeImagesToOrigin === true || (config.security?.strict && config.security?.restrictRuntimeImagesToOrigin == null)
16301650
? []
16311651
: (config.security?.restrictRuntimeImagesToOrigin || false),
1632-
secret: config.security?.secret || process.env.NUXT_OG_IMAGE_SECRET || '',
1652+
secret: resolvedSecret,
16331653
},
16341654
}
16351655
if (nuxt.options.dev) {
@@ -1647,10 +1667,15 @@ export const rootDir = ${JSON.stringify(nuxt.options.rootDir)}`
16471667
// Read by useOgImageRuntimeConfig and prefers this value over security.secret
16481668
// when set, allowing runtime overrides on platforms like Cloudflare Workers
16491669
// where env bindings are surfaced through the event context.
1670+
// Only an EXPLICIT secret is baked into this override channel — never the
1671+
// auto-generated one. Leaving it empty when auto-generating keeps the
1672+
// `NUXT_OG_IMAGE_SECRET` runtime override (e.g. Cloudflare env bindings)
1673+
// reachable; the auto value lives solely in the build-time `security.secret`
1674+
// fallback below, which the runtime override still supersedes.
16501675
const existingOgImageCfg = (nuxt.options.runtimeConfig as Record<string, any>).ogImage
16511676
;(nuxt.options.runtimeConfig as Record<string, any>).ogImage = {
16521677
...(existingOgImageCfg && typeof existingOgImageCfg === 'object' ? existingOgImageCfg : {}),
1653-
secret: (existingOgImageCfg as any)?.secret || config.security?.secret || process.env.NUXT_OG_IMAGE_SECRET || '',
1678+
secret: (existingOgImageCfg as any)?.secret || (signing.hasExplicit ? signing.secret : ''),
16541679
}
16551680

16561681
// Non-sensitive subset exposed to the browser so defineOgImage can refresh

test/fixtures/basic/nuxt.config.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,12 @@ export default defineNuxtConfig({
1313

1414
ogImage: {
1515
debug: true,
16+
// URLs are signed by default; these tests assert unsigned dynamic URLs and
17+
// hand-construct /_og/d/ requests. Signing is covered by the url-signing unit
18+
// tests and the cloudflare-runtime-config e2e.
19+
security: {
20+
secret: false,
21+
},
1622
},
1723

1824
routeRules: {

test/fixtures/cloudflare-satori/nuxt.config.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,11 @@ export default defineNuxtConfig({
2424
defaults: {
2525
renderer: 'satori',
2626
},
27+
// Signing is on by default; this fixture hand-constructs unsigned /_og/d/
28+
// requests at runtime. Disable signing here (covered elsewhere).
29+
security: {
30+
secret: false,
31+
},
2732
},
2833

2934
nitro: {

test/fixtures/vercel-edge-satori/nuxt.config.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,11 @@ export default defineNuxtConfig({
1616
defaults: {
1717
renderer: 'satori',
1818
},
19+
// Signing is on by default; this test rewrites the static og:image URL to an
20+
// unsigned /_og/d/ request. Disable signing here (covered elsewhere).
21+
security: {
22+
secret: false,
23+
},
1924
},
2025

2126
nitro: {

test/unit/signing-secret.test.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import { describe, expect, it, vi } from 'vitest'
2+
import { resolveSigningSecret } from '../../src/build/signing-secret'
3+
4+
const gen = () => 'GENERATED'
5+
6+
describe('resolveSigningSecret', () => {
7+
it('uses an explicit config secret', () => {
8+
const r = resolveSigningSecret('cfg-secret', undefined, gen)
9+
expect(r).toEqual({ secret: 'cfg-secret', hasExplicit: true, optOut: false, generated: false })
10+
})
11+
12+
it('falls back to the env secret when no config value', () => {
13+
const r = resolveSigningSecret(undefined, 'env-secret', gen)
14+
expect(r).toEqual({ secret: 'env-secret', hasExplicit: true, optOut: false, generated: false })
15+
})
16+
17+
it('prefers the config secret over the env secret', () => {
18+
const r = resolveSigningSecret('cfg-secret', 'env-secret', gen)
19+
expect(r.secret).toBe('cfg-secret')
20+
})
21+
22+
it('auto-generates when neither config nor env is set', () => {
23+
const generate = vi.fn(() => 'RANDOM')
24+
const r = resolveSigningSecret(undefined, undefined, generate)
25+
expect(generate).toHaveBeenCalledOnce()
26+
expect(r).toEqual({ secret: 'RANDOM', hasExplicit: false, optOut: false, generated: true })
27+
})
28+
29+
it('treats an empty-string config/env as unset and auto-generates', () => {
30+
const r = resolveSigningSecret('', '', gen)
31+
expect(r).toEqual({ secret: 'GENERATED', hasExplicit: false, optOut: false, generated: true })
32+
})
33+
34+
it('opts out of signing entirely on `secret: false`', () => {
35+
const generate = vi.fn(gen)
36+
const r = resolveSigningSecret(false, 'env-secret', generate)
37+
// Opt-out wins over an env var, and never generates.
38+
expect(r).toEqual({ secret: '', hasExplicit: false, optOut: true, generated: false })
39+
expect(generate).not.toHaveBeenCalled()
40+
})
41+
})

0 commit comments

Comments
 (0)