Gating Private Files in a Headless CMS
How a decoupled Drupal 11 platform withholds a private file until a visitor has earned it — without leaking the file's path, coupling the CMS to any one front end, or trusting the browser with anything it could forge — and why we turned the result into an open-source module.
The word "private" is doing too much work
Attach a file to a piece of content, mark its storage private, and it is natural to assume the file is now protected. In a decoupled system that assumption is quietly wrong, and the gap it hides is the reason this paper exists.
Drupal decides who may download a private file by asking a simple question: can this user view the entity that references the file? For published content the answer, for anonymous visitors, is yes. So a private file attached to a published page is downloadable by anyone who has — or guesses, or is handed — the link. The private:// scheme means "not sitting in a public directory," not "released only to people who have earned it." It is an obfuscation, not a gate.
For most files that distinction never surfaces. For the files a business actually wants to gate — a whitepaper behind a lead form, a report behind a login, a deliverable behind a purchase — it is the whole ballgame. And a decoupled architecture makes it sharper still: the front end that owns the gate (the lead form, the session, the checkout) is a different system from the CMS that owns the file. Something has to bridge them without handing the browser a URL that bypasses the gate entirely.
Principle: the gate belongs to the consumer, the grant belongs to the platform
The first decision is a division of labor, and getting it right is what keeps the system decoupled. There are two distinct jobs, and they belong in two different places.
Deciding whether a visitor has passed the gate is the front end's job. The gate is a lead form, an authenticated session, a completed purchase — business logic that lives where the visitor is, in the consuming application. The CMS has no opinion about how that gate works and should never try to have one; the moment it does, it is coupled to that particular front end and cannot serve the next one.
Deciding whether to release the bytes is the platform's job. Once the front end asserts "this visitor is allowed," something trusted has to turn that assertion into an actual, verifiable permission to download one specific file for a short window. That is a cross-cutting concern — a native app, a second site, or an integration will each need the same capability — so it belongs in the CMS, exposed the same way to every consumer.
The bridge between the two is a signed grant: the front end passes its gate however it likes, then asks the platform to mint a short-lived, cryptographically signed permission for exactly one file. The browser redeems that permission and gets the bytes. Nothing about React, Next.js, a native client, or a coupled Twig theme is assumed anywhere in that exchange — which is precisely what makes it reusable.
Deny at the file, allow on your own route
The mechanism follows directly from the principle. If the default download path grants access based on "can you view the content," then the default download path cannot be trusted for a gated file — so the first move is to take it off the table.
File Gate implements Drupal's file-download hook to return a hard veto — a definitive "no," not a polite abstention — for any private file referenced by a field that has been marked gated. That veto overrides core's permissive private-file access. The ordinary /system/files path, the one that would otherwise hand the file to any anonymous visitor, is closed for gated files and never reopened there. The only exception is a Bypass file gate permission, so trusted editors can still download through the admin UI while they work.
Delivery instead happens on a route the module owns. The visitor's browser redeems a signed URL at a dedicated download endpoint; the module verifies the grant and streams the file directly, as a binary response marked Cache-Control: private, no-store. Two properties fall out of this that a "just make core allow it" approach cannot offer:
- The allow-path is independent of core's access rules. Redemption does not depend on the visitor being able to view the referencing entity, so gating a file never requires loosening anything else — and can never be loosened by accident when content is published.
- The storage path is never disclosed. The signed URL carries the file's UUID, not its
private://location. The path a decoupled API would otherwise expose — the exact thing that makes "not guessable" fail — is simply never in the response.
The grant is a signed promise the client cannot rewrite
A short-lived signed URL is only safe if none of the things that make it safe can be edited by the person holding it. So every constraint that matters is folded into the signature itself.
The grant is an HMAC-SHA256 signature over the normalized file identifier and a canonical bag of claims — the expiry, an optional not-before, and any usage token or cap. Change the file, stretch the expiry, or lift the usage limit, and the signature no longer matches; the download is refused. Verification is constant-time (hash_equals()), so the comparison itself leaks nothing about the correct value. The signing secret lives in the environment, never in exported configuration, and is only ever used server-side — the browser sees the derived signature, never the key that produced it.
Minting is deliberately a server-to-server exchange. The front end's back end calls the mint endpoint with the file or media UUID and the shared secret; the platform returns a root-relative, host-agnostic, TTL-limited path for the browser to redeem. The secret never reaches the browser. The public artifact is always the signature, never the capability to produce one.
Fail closed, on purpose
Security defaults are a design choice, and the honest default for a gate is to be shut. If no signing secret is configured, File Gate does not fall back to serving files "for now" — it refuses. Minting returns a 503, and every gated file stays denied. A misconfiguration, a missing environment variable, a half-finished deploy: each produces a visible failure, not a silent leak. The failure mode of "the gate isn't wired up yet" is that nothing gets out, which is the only failure mode a gate is allowed to have.
A gated field cannot store a public file
Gating is configured where it belongs — on the field — and it is enforced at the level where mistakes happen. Marking a file or image field gated does two things at once: it forces the field's storage to the private file system and locks that control, because a public, world-readable file cannot be gated in the first place. There is no configuration in which a field is "gated" but quietly writing files to a public directory. The setting travels with exported configuration as a third-party setting on the field storage, so the guarantee is reproducible across environments rather than a manual checkbox someone has to remember.
The dependency footprint is deliberately small: the only hard requirement is core's File system. Gating works on a Media source field, a plain file field, or any other private file field, so a site can adopt it without buying into a particular content model.
Expose the metadata, not the URL
A decoupled front end still needs to render the gated resource — its name, its type, its size, the fact that it exists at all — so a visitor can decide to pass the gate for it. The rule that keeps this safe is to expose all of that metadata through the API (JSON:API or GraphQL) while withholding the one field that would defeat the gate: a directly resolvable file URL. The front end knows there is a document called "2026 Compliance Report," 2.4 MB, PDF; it does not receive a link that streams the bytes. That link only ever comes into existence when the back end mints one, after the gate, for one visitor, for a short time.
The seam that makes it more than a file gate
The most valuable decision in the design is one a user never sees: the signing and minting core does not know it is dealing with files. It operates on an opaque resource identifier and a bag of claims. Only three narrow pieces are file-specific — the download hook that denies /system/files, the route that streams the bytes, and the field control that forces private storage. Everything about proving "this grant is valid for this resource until this time" is target-agnostic.
That seam is not decoration. It means the same verified mechanism that gates a file can gate a different kind of resource later — a whole piece of content, an API response, an entity — without re-litigating the cryptography. And the question of how a request proves it passed the gate is itself pluggable: a small plugin type lets a site add a gate method in a few lines. Eight ship today — a signed URL, authenticated-user delivery, revocable and pre-shared tokens, a referrer/origin lock, an emailed one-time passcode, a coupled lead-capture form, a purchase/entitlement gate, and a hardware-backed OIDC assurance level (PIV/CAC or FIDO2/WebAuthn) — and each arrived as a plugin, not a rewrite. We built the bespoke thing as though it were a product, because a capability that solves a recurring problem across clients is a product in disguise, and the discipline of building it as one is what lets it travel.
Be honest about the trust boundary
Every security system has a line it draws and a set of things it does not promise, and refusing to name them is how sensible-looking systems fail. File Gate draws its line clearly.
The mint endpoint trusts the caller that holds the secret. It does not — cannot — re-verify the front end's own gate; that logic lives in the front end by design. The security of the whole arrangement therefore rests on two operational facts: the secret stays secret, and the mint endpoint stays on a trusted network and is rate-limited against abuse. Those are deployment responsibilities the module makes explicit rather than papering over.
And the usage limits — "this link may be redeemed once" — are enforced with a fast expirable counter, not a hard lock. Under a tight race, a determined client might squeeze out one extra redemption. That is entirely adequate for lead generation and casual limits, and it is not a licensing enforcement mechanism. Saying so plainly is part of the design: a gate that overstates its guarantees is worse than one that states them exactly.
Principles worth keeping
The module matters less than the principles the work surfaced, which apply to gating any resource on any headless platform:
- "Private" is not "gated." Not-in-a-public-directory and released-only-after-a-gate are different guarantees. Know which one you actually have.
- Split the gate from the grant. Deciding who passes belongs to the consumer that owns the interaction; issuing a verifiable permission belongs to the platform that owns the resource. Keep them apart and the platform stays reusable.
- Deny at the source, allow on your own terms. If a default path grants access for the wrong reason, close it and deliver through a route whose rules you control — and which leaks no path.
- Bind everything into the signature. Any constraint the client could otherwise edit — the file, the expiry, the usage cap — must be part of what is signed, or it is not a constraint. And it must be encoded unambiguously before it is signed: if two different claim sets can serialize to the same bytes, a bound claim can be quietly dropped while the signature still verifies, so the canonical form has to be injective.
- Fail closed. The safe default for a gate is shut. A missing secret should stop delivery, loudly, not degrade to serving files.
- Name the trust boundary. State what you trust and what you do not promise. A gate that is honest about its edges is stronger than one that pretends it has none.
Gating a private file sounds like a checkbox and turns out to be an architecture problem: where the gate lives, who may issue a grant, what the grant is allowed to promise, and what happens when the wiring is incomplete. Answering those questions deliberately produced a capability that is correct by construction, decoupled from any one front end, and reusable well beyond the file it started with — the open-source File Gate module.