Why the plain URL returns 401
You have a PDF endpoint behind a bearer token, and dropping that URL straight into an iframe fails. The reason is that the browser builds that request, not your code, and the element only takes a URL. MDN lists twelve attributes on the iframe element: allow, browsingtopics, credentialless, csp, height, loading, name, referrerpolicy, sandbox, src, srcdoc and width. None of them sets a request header. Cookies are a different story, because the browser attaches those itself, subject to SameSite, whose Lax value excludes navigations inside iframe elements from its cross-site allowance. So a same-site session cookie rides along and a bearer token has no way in. If your documents are cookie-authenticated and same-site, you do not need any of what follows.
Fetch the bytes, then hand the viewer a blob URL
// Renders a token-protected PDF into `container`.
// Returns a cleanup function to call when the viewer goes away.
export async function showPdf(url, token, container) {
const res = await fetch(url, {
headers: { Authorization: `Bearer ${token}` },
})
if (!res.ok) throw new Error(`PDF request failed: ${res.status}`)
// Set the type explicitly so the viewer treats the bytes as a PDF.
const bytes = await res.arrayBuffer()
const blobUrl = URL.createObjectURL(
new Blob([bytes], { type: 'application/pdf' })
)
const frame = document.createElement('iframe')
frame.src = blobUrl
frame.title = 'Document preview'
frame.style.cssText = 'width:100%;height:100%;border:0'
container.replaceChildren(frame)
return () => {
frame.remove()
URL.revokeObjectURL(blobUrl)
}
}Your code makes the authenticated request, so the token travels on an ordinary fetch, and the iframe only ever sees the blob URL. The document endpoint never appears in the markup.
Three things that will bite you
Revoke the URL. Per MDN's blob URL docs, each call to createObjectURL creates a new object URL even for the same object, each one must be released with revokeObjectURL, and while one is active the underlying object cannot be garbage collected. Browsers do release them when the document unloads, which is no help in a single-page app where the document never unloads. Revoke when the resource is no longer reachable by the user, not the moment it finishes rendering, or you break right-click and open-in-new-tab.
Check your CSP. The frame-src directive specifies the valid sources for nested browsing contexts loaded by frame and iframe elements, and when it is absent the browser falls back to child-src, which falls back to default-src. If a policy is in play and blob: is not an allowed source, the frame renders empty with no network error to explain why.
This buffers the whole file. Every byte lands in memory before anything renders, which is fine for a two-page agreement and bad for a large scan. If your documents are big, have the server mint a short-lived signed URL instead and let the browser stream it into its native viewer.
Back to All Questions