diff --git a/packages/img/README.md b/packages/img/README.md
index 0b78f87f..954a696b 100644
--- a/packages/img/README.md
+++ b/packages/img/README.md
@@ -85,6 +85,10 @@ non-normalized Unicode, or more than 1024 UTF-8 bytes are rejected before signin
### Direct delivery
+Choose the delivery policy based on the page's lifetime: use authorized redirects for cached
+markup and pages that may outlive a CDN signature. Use direct delivery for request-authorized
+galleries that do not need a new application authorization check when each image loads.
+
Direct delivery is the default and fits image-heavy views that already authorize their data while
rendering. The component calls Next.js `connection()` before creating short-lived signed URLs. A
built-in Suspense boundary lets a Cache Components page prerender a shell, but the signed image
@@ -95,8 +99,8 @@ image that has no source and makes no request. `suspenseFallback` explicitly rep
The browser requests the selected candidate directly from Smart CDN. Lazy loading remains the
platform default. A candidate first requested after its signature expires can fail on an unusually
-long-lived page; choose an appropriate bounded `expiresInMs`, eagerly load a measured critical
-image, or use authorized redirect delivery.
+long-lived page. Prefer authorized redirects for that case; a longer direct signature only delays
+the boundary and also extends the lifetime of a URL that has already been issued.
### Authorized redirects
@@ -120,6 +124,8 @@ export const { Image, storageRoute } = createTransloaditImage({
basePath: '/app',
route: '/api/private-images',
},
+ expiresInMs: 5 * 60 * 1000,
+ rotationIntervalMs: 30 * 1000,
},
workspace,
})
@@ -137,18 +143,31 @@ The handler rejects changed, duplicate, unknown, oversized, or malformed capabil
calling application authorization. `authorize` must return the boolean `true` for the current
request.
+Use browser-attached credentials, normally your same-origin session cookie, in `authenticate`.
+Native image requests cannot attach an application-defined Bearer header.
+
After authorization, the handler returns a private, non-cacheable `307` to a fresh signed Smart CDN
URL. Image bytes still bypass Next.js. Rotating the Transloadit secret invalidates existing
capabilities, so redeploy cached static markup at the same time.
+The example issues CDN grants valid for at least five minutes and at most five minutes thirty
+seconds, including the rotation window. These are explicit example settings, not new defaults;
+allow enough time for a cold transformation. Cached capabilities can still request a new grant
+after an earlier CDN URL has expired, provided application authorization continues to allow access.
+
+Revoking application access denies **new redirect grants**. It does not invalidate signed CDN URLs
+already handed to a browser: those remain valid until their expiry. Downloaded bytes cannot be
+recalled. Shorter grants bound this remaining access window; they do not provide instant revocation.
+
| Property | Direct, the default | Authorized redirect |
| --- | --- | --- |
| Next.js work per loaded image | None | One authorization + redirect |
| Image bytes through Next.js | Never | Never |
| Shared/static image markup | No | Yes |
-| Request-time revocation | No | Yes |
+| Application checks for new image grants | During page rendering | On each redirect request |
+| Already-issued CDN URLs | Valid until expiry | Valid until expiry |
| Long-lived lazy pages | Signature can expire | Fresh CDN signature per load |
-| Typical fit | Large authorized galleries | Strict ACLs and revocation |
+| Typical fit | Request-authorized galleries | Cached markup and long-lived private pages |
## Responsive policy
diff --git a/packages/img/test/next-server.test.tsx b/packages/img/test/next-server.test.tsx
index 421840bf..be4feb58 100644
--- a/packages/img/test/next-server.test.tsx
+++ b/packages/img/test/next-server.test.tsx
@@ -481,6 +481,50 @@ describe('createTransloaditImage', () => {
expect(connection).not.toHaveBeenCalled()
})
+ test('refreshes an expired target from cached markup, then denies new grants after revocation', async () => {
+ const authorize = vi.fn(() => true)
+ const { Image, storageRoute } = createTransloaditImage({
+ ...baseConfiguration,
+ storage: {
+ allowedPathPrefixes: ['documents/'],
+ delivery: { authorize, route: '/api/private-images' },
+ expiresInMs: 5 * 60 * 1000,
+ rotationIntervalMs: 30 * 1000,
+ },
+ })
+ const document = parseMarkup(
+ renderToStaticMarkup(
+ ,
+ ),
+ )
+ const originalCapability = new URL(getFirstCandidate(document), 'https://app.example')
+ const firstResponse = await storageRoute(new Request(originalCapability))
+ const firstLocation = firstResponse.headers.get('location')
+ if (firstLocation === null) throw new Error('Expected the first authorized target')
+ const originalExpiry = Number(new URL(firstLocation).searchParams.get('exp'))
+ expect(originalExpiry).toBe(Date.parse('2029-01-01T12:07:30Z'))
+
+ vi.setSystemTime(originalExpiry + 1)
+ const renewed = await storageRoute(new Request(originalCapability))
+ const renewedLocation = renewed.headers.get('location')
+ if (renewedLocation === null) throw new Error('Expected a renewed authorized target')
+ expect(renewed.status).toBe(307)
+ expect(renewedLocation).not.toBe(firstLocation)
+ expect(Number(new URL(renewedLocation).searchParams.get('exp'))).toBe(
+ Date.parse('2029-01-01T12:13:00Z'),
+ )
+ expect(renewed.headers.get('cache-control')).toBe('private, no-store')
+ expect(await renewed.text()).toBe('')
+
+ authorize.mockReturnValue(false)
+ const denied = await storageRoute(new Request(originalCapability))
+ expect(denied.status).toBe(404)
+ expect(denied.headers.get('location')).toBeNull()
+ expect(denied.headers.get('cache-control')).toBe('private, no-store')
+ expect(await denied.text()).toBe('')
+ expect(authorize).toHaveBeenCalledTimes(3)
+ })
+
test('binds capabilities to the secret, workspace, Template, route, and basePath', async () => {
const { url } = getStorageRouteCandidate()
const authorize = vi.fn(() => true)
diff --git a/scripts/fixtures/img-next/app/TransloaditRedirectImage.tsx b/scripts/fixtures/img-next/app/TransloaditRedirectImage.tsx
index bfbd58e1..64dddbb8 100644
--- a/scripts/fixtures/img-next/app/TransloaditRedirectImage.tsx
+++ b/scripts/fixtures/img-next/app/TransloaditRedirectImage.tsx
@@ -11,6 +11,8 @@ const { Image, storageRoute } = createTransloaditImage({
basePath: '/fixture',
route: '/api/private-images',
},
+ expiresInMs: 5 * 60 * 1000,
+ rotationIntervalMs: 30 * 1000,
},
})
diff --git a/scripts/test-img-next-fixture.ts b/scripts/test-img-next-fixture.ts
index 79cb1caf..f3927c6e 100644
--- a/scripts/test-img-next-fixture.ts
+++ b/scripts/test-img-next-fixture.ts
@@ -335,11 +335,19 @@ async function main(): Promise {
const routeCandidate = getFirstPictureCandidates(redirectHtml)[0]
assert(routeCandidate !== undefined, 'Expected a redirect route candidate')
const routeUrl = new URL(routeCandidate, baseUrl)
+ const beforeAuthorization = Date.now()
const allowed = await fetch(routeUrl, {
headers: { Authorization: 'Bearer fixture' },
redirect: 'manual',
})
assert(allowed.status === 307, 'Authorized Storage route did not redirect')
+ const location = allowed.headers.get('location')
+ assert(location !== null, 'Authorized Storage route has no target')
+ const expiresAt = Number(new URL(location).searchParams.get('exp'))
+ assert(
+ expiresAt >= beforeAuthorization + 5 * 60 * 1000 && expiresAt <= Date.now() + 330_000,
+ 'Redirect fixture did not use the documented five-minute plus 30-second grant',
+ )
assert(
allowed.headers.get('location')?.startsWith('https://cdn.example/') === true,
'Authorized Storage route did not target Smart CDN',