Gate access to private files with pluggable methods (short-lived signed URLs, authenticated access, …) and deliver them to any decoupled front end.
Out of the box, Drupal grants a private-file download to anyone who can view
the entity that references it. Because anonymous users can view published
content, a private file attached to published media is effectively public to
anyone who has the link — private:// only means "not guessable", not "gated".
File Gate closes that gap. It denies the normal /system/files path for any
private file a gated field references, and delivers those files instead through
its own endpoint, only after a pluggable gate method approves the request.
(1) editor uploads a file to a gated, private:// field
│
visitor ──(2)──▶ your front end ──(3, server-to-server)──▶ POST /api/file-gate/mint
▲ passes the front end's gate (file/media UUID + shared secret)
│ (lead form, login, purchase…) │
│ ▼
└─────(5) browser redeems ◀──(4)── { path, expires, ttl } (short-lived signed URL)
GET /api/file-gate/download?f=…&exp=…&sig=…
│
▼
File Gate verifies the grant and streams the bytes.
Meanwhile GET /system/files/… stays denied for the public.
- A private file/image field is marked gated (see Configuration).
- A visitor passes your gate — a lead form, a login, a purchase — entirely in your front end. File Gate does not care how you gate; it only mints and verifies.
- Your back end calls the mint endpoint (server-to-server) with the file or media UUID and the shared secret.
- File Gate returns a relative, short-lived, signed path.
- The visitor's browser redeems it at the download endpoint. File Gate
verifies the signature (and any usage limit) and streams the file. The raw
/system/filespath is denied to the public throughout.
- Pluggable gate methods. A clean plugin type (
GateMethod) decides how a request proves it passed the gate. Ships with Signed URL (HMAC), Authenticated access, Token (revocable per-grant links and pre-shared campaign tokens), Referrer lock (a signed URL restricted to an allowed origin), and One-time passcode (email-verified); optional submodules add File Gate Form (a coupled email / lead-capture form Drupal renders), File Gate Commerce (gate behind a Drupal Commerce purchase or a pluggable entitlement), and File Gate Assurance (hardware-backed, phishing-resistant PIV/CAC + FIDO2/WebAuthn gating over any OIDC IdP, with opt-in DPoP). Add your own in a few lines. - Front-end agnostic / headless-first. Mint over a server-to-server API; redeem in the browser. Nothing about React, Next.js, Vue, or a coupled Twig theme is assumed. Works for decoupled, coupled, and hybrid sites.
- Per-field configuration. Mark any file/image field gated where you choose its storage; enabling gating forces and locks the private file system so a gated field can never silently store public, world-readable files.
- Media-optional. Gate a Media source field, a plain file field, or any other private file field. The only hard dependency is core File.
- Expiry & usage controls. Per-field TTL, an absolute availability window
(
available_until), and usage limits (max_uses, including one-time links). All are bound into the signature and cannot be altered by the client. - Fails closed. With no signing secret configured, minting returns
503and every gated file is denied. - Constant-time verification. HMAC-SHA256 grants compared with
hash_equals(); the secret lives in the environment, never in configuration. - Editors keep working. A Bypass file gate permission lets trusted staff download gated files normally through the admin UI.
- Auditing & analytics. A dedicated logger channel records security events
(denied downloads, failed mint auth, fail-closed refusals) and usage events
(mints and deliveries). Optional soft integration with audit_chain for a
hash-chained durable trail (
docs/AUDIT.md). Audit rows identify files asfile:<id>— never the original filename. Optionalaudit_require_keyed(off by default) refuses mint and download with HTTP 503 when a signed audit append fails. - Admin dashboard. Secret status, defaults, gate methods, gated fields, plus
mint/delivery/denial tables from the
file_gatedblog channel when Database Logging is enabled. - Secret rotation with dual keys, multi-field mint pin, forced identity mint,
mint-time OIDC (A2), self-only WebAuthn enrollment UI — see
CHANGELOG.mdanddocs/SECRET_ROTATION.md,docs/E2E-ASSURANCE.md. - IdP-first (Keycloak) architecture:
docs/KEYCLOAK-UNIFIED-AUTH.md. Roadmap and scheduling are tracked internally rather than in this repository. - Optional identity-aware mint. Pass
account(user UUID) oruidon mint so a grant cannot exceed that user's download rights, view rights on every referencing host (including auserhost), view rights on the referencing field, and view rights on nested parents (getParentEntity(), e.g. paragraphs). A file with no resolvable host is refused.
- Base module: Drupal 10.6 / PHP 8.2, Drupal 11.3+ / PHP 8.3+, or Drupal 12.
Drupal 11.4 uses core's
FileReferenceResolver; older core uses a revision-aware backport with the same host and field access checks. The backport recomputes references because older core cannot invalidate the native resolver's cache tags. - Optional submodules retain their separately declared Drupal 11.4 floor. The supported-core workflow exercises the base module; the existing full suite covers optional integrations.
- A configured private file system (
file_private_path). - A signing secret, injected from the environment (below).
composer require drupal/file_gate
drush en file_gateSecrets are never stored in exported configuration. While none are set the module fails closed.
Legacy (single secret, whole gated corpus) — fine for one trusted mint backend:
$config['file_gate.settings']['download_secret'] = getenv('DRUPAL_FILE_GATE_SECRET');Named secrets (field-scoped) — use when different front ends must not mint
each other's files. Values in $settings (not $config); scopes in config
(admin form or export):
// settings.php — values never export.
$settings['file_gate.secrets'] = [
's_public' => getenv('FILE_GATE_SECRET_PUBLIC'),
's_nda' => getenv('FILE_GATE_SECRET_NDA'),
];
// Scopes (exportable) — or set under Admin → File Gate "Named secret scopes":
// s_public: node.field_whitepaper
// s_nda: node.field_nda_pdf
$config['file_gate.settings']['secret_scopes'] = [
's_public' => ['node.field_whitepaper'],
's_nda' => ['node.field_nda_pdf'],
];Mint with Basic auth username = secret id, password = value. Minted URLs
include k=<id> so redemption uses the same key. Scope is checked at mint and
again at download against the signed fld claim, or the field stored on
the grant when fld is absent (narrowing a secret revokes outstanding
grants). An unsigned body field is not a redeem pin. A named
secret with a value but no scope can mint nothing and is reported on the status
report. Keep secrets off the public network; only signatures reach the browser.
Edit any private file or image field. In its settings you will find "Gate access to these files". Enabling it:
- forces the field's storage to the private file system and locks that control; and
- lets you pick a gate method (default: Signed URL).
Gating is stored as third-party settings on the field storage, so it travels with your exported configuration.
Per-method options (for Signed URL, under the field's method_settings):
| Setting | Meaning |
|---|---|
ttl |
Signed-URL lifetime in seconds (defaults to the global TTL). |
available_until |
Absolute Unix timestamp; caps every grant's expiry (a "download available until X" window). |
max_uses |
Maximum redemptions per minted URL (1 = one-time link). |
The Token method (token) adds the one thing a bare signature cannot do:
revoke a single live link without rotating the site secret (which would break
every other link). It offers two shapes, chosen at redemption by whether the URL
carries a signature:
- Minted, revocable (per grant).
mintself-issues a random token, records its SHA-256 hash, and binds that hash into the signed URL. Revoke a link by deleting its stored hash — via the revoke endpoint or by removing the key from thefile_gate_tokensexpirable key/value store. Becausemintreceives no caller input, a token is per-grant / independently revocable — attach recipient meaning (token → person) in your own back end. - Pre-shared campaign. Configure an allowlist of token hashes and hand
out static
?token=<plaintext>links (nomint). Revoke by removing the hash from configuration. These links are static — no per-request expiry.
Per-field method_settings:
| Setting | Meaning |
|---|---|
ttl, available_until, max_uses |
Same as Signed URL, applied to minted tokens. |
tokens |
Array of SHA-256 hashes of pre-shared tokens (e.g. hash('sha256', $token)). Store hashes only — never the plaintext token, which travels only in the recipient's link. Leave empty to disable pre-shared mode. |
Tokens are stored and configured only as hashes, so a store or config dump yields no usable credentials. Like Signed URL's usage counter, the revocation store is a fast key/value store, not a lock: a redemption whose use-increment races a manual revocation could admit one extra request. Adequate for lead-gen and distribution; not a hard licensing lock.
The Referrer lock method (referrer_lock) is a Signed URL that is
additionally only redeemable from an allowed origin. It mints and validates the
same signed grant (and inherits ttl, available_until, and max_uses), and at
redemption also checks the request's Origin header — falling back to the origin
of the Referer — against a per-field allowlist. The origin is checked first, so
a request from a disallowed origin is denied before a usage-limited grant would
spend one of its uses.
Hardening, not authorization. The
Origin/Refererheader is trivially spoofable by any non-browser client and is often stripped by privacy setups, so this is not an access boundary — the signature is. Use it to discourage a leaked link from working when embedded on another site, not to protect anything the signature alone should not already protect.
Per-field method_settings (in addition to all of Signed URL's):
| Setting | Meaning |
|---|---|
allowed_origins |
List of allowed origins, e.g. https://app.example.com. Matched as scheme + host + (non-default) port. An empty list denies every request (fail closed) — configure at least one. |
on_missing_referrer |
What to do when no parseable Origin/Referer is available: deny (default) or allow (tolerate privacy setups that strip the header, leaning on the signature alone). |
The optional File Gate Assurance submodule (file_gate_assurance) adds the
assurance method: a signed URL whose delivery also requires a hardware-backed,
phishing-resistant NIST SP 800-63 assurance level proven at any OIDC IdP —
PIV/CAC (HSPD-12 / FIPS 201) or FIDO2/WebAuthn — with opt-in DPoP (RFC 9449)
sender-constraining. A field trusts a list of issuers (trusted_issuers),
each with its own audience and accepted acr values; a token is matched to
exactly one entry by iss, with no cross-matching and no fallback, and the
legacy single-issuer settings behave as a one-entry list. It is federation
(an asserted level), not File Gate acting as an AAL3 verifier. See
modules/file_gate_assurance/README.md
and the design note in
docs/design/piv-cac-webauthn.md.
The One-time passcode method (otp) proves control of an email address: a
trusted back end requests a code bound to (file, email) at POST /api/file-gate/otp (shared-secret auth, like mint), File Gate emails it, and the
visitor exchanges the code at POST /api/file-gate/otp/session (JSON body:
file, email, otp). That sets an HttpOnly FG_OTP cookie; the browser then
downloads with GET /api/file-gate/download?f=… and no passcode in the URL. The
code is single-use, TTL-limited, attempt-locked, and stored only as a hash. A
step up from a bare token — verified in the moment — without an account.
| Setting | Meaning |
|---|---|
ttl |
Code lifetime in seconds (default 600). |
max_attempts |
Wrong-code tries before the code is locked out (default 5). |
code_length |
Number of digits in the passcode (default 6). |
Email is not a confidential channel. The passcode proves control of the address, not that the message is secret — anyone who can read the recipient's mail (or intercept it without transport encryption) can use the code within its window. Use
otpto gate lead-gen / self-service documents, not to protect content a real secret should protect; keep the TTL short.
The optional File Gate Form submodule (file_gate_form) adds the form
method: Drupal renders a lightweight email / lead-capture form at
/file-gate/form/{file} and grants the download on submission (a per-session
grant in the private tempstore, TTL-limited). Spam is bounded by a honeypot and a
per-IP rate limit, and a LeadCapturedEvent lets the site persist submissions
without File Gate storing PII. See
modules/file_gate_form/README.md.
The optional File Gate Commerce submodule (file_gate_commerce) adds the
commerce method: deliver only to a buyer / licensee, re-checked live on every
download. The bundled checker grants on a completed Drupal Commerce order matching
a configured SKU; override the file_gate_commerce.entitlement_checker service
for licences or an external entitlement API. Access gating, not DRM. See
modules/file_gate_commerce/README.md.
Visit Administration → Configuration → Media → File Gate
(/admin/config/media/file-gate) to set the default TTL, content disposition
(attachment/inline), and mint rate limiting, to check the secret status, and to
see every gated field. Optional Refuse delivery if a signed audit write
fails (audit_require_keyed, default off) makes mint and download call
audit_chain logKeyed() and return HTTP 503 if that signed append fails.
See docs/AUDIT.md.
Server-to-server only. Authenticate with the shared secret as the HTTP Basic
password (or the X-File-Gate-Secret header).
Request body (JSON):
{ "media": "<media-uuid>" }or
{ "file": "<file-uuid>" }Response 200:
{
"path": "/api/file-gate/download?f=<file-uuid>&exp=1720800000&sig=<hmac>",
"expires": 1720800000,
"ttl": 120
}path is root-relative and host-agnostic — prepend your public Drupal file
origin. Errors: 503 (no secret configured, or audit_require_keyed is on
and the signed audit write failed), 401 (bad/absent secret), 404
(unknown file/media), 409 (host media unpublished), 422 (file not gated /
empty), 429 (rate limited), 400 (bad request).
Redeemed by the visitor's browser. Returns the file stream (200) or 403 when
the grant is missing, expired, tampered, or spent; 404 when the file is unknown
or not gated; 503 when audit_require_keyed is on and the signed audit write
failed (generic body, no file bytes).
For the Token method the browser also sends token (the plaintext token); a
pre-shared campaign link sends only token (no exp/sig). For the OTP
method the browser first POSTs email and otp in the JSON body to
/api/file-gate/otp/session, then GETs the download with the FG_OTP
cookie. The passcode is never a query parameter.
Server-to-server, same shared-secret authentication as mint. Invalidates one
minted grant without rotating the site secret. Pre-shared campaign tokens are
revoked by removing their hash from the field's tokens configuration, not
here.
Request body (JSON) — exactly one of:
{ "token": "<plaintext-token>" }
{ "jti": "<signed-url-jti>" }token deletes a minted token-method hash (field-scoped when the stored
field is known). jti spends a usage-limited signed_url grant and drops it
from inventory; the secret must be allowed for the grant's stored field, and a
named secret must match stored k.
A jti revoke is a kill mark: a redemption counter set to its maximum, stored
with an expiry. The mark lasts until the grant's own expiry plus one hour, and
never less than 30 days. An optional "ttl" (seconds) can lengthen it. A ttl
shorter than the grant's remaining life is raised to it, because a mark that
lapses first would make the revoked URL work again. Revoking the same grant
again never shortens its mark. Bulk revoke
(POST /api/file-gate/grants/revoke-bulk) follows the same rule.
Responses: 204 (revoked), 400 (no token/jti), 401 (bad/absent secret),
403 (credential not allowed for that grant's field / k), 404 (unknown or
already-gone token or jti), 429 (rate limited), 503 (no secret configured).
Server-to-server, same shared-secret authentication as mint. Issues a single-use
passcode bound to a (file, email) pair and emails it to that address (File Gate
sends the mail and stores only a hash of the code). The visitor then redeems by
posting the code in the JSON body to /api/file-gate/otp/session and downloading
with the FG_OTP cookie (GET /api/file-gate/download?f=…).
Request body (JSON):
{ "file": "<file-uuid>", "email": "person@example.com" }(or "media": "<media-uuid>" in place of "file".)
Responses: 204 (code issued and emailed), 400 (invalid body or email),
401 (bad/absent secret), 404 (unknown file/media), 409 (host media
unpublished), 422 (file not gated with the otp method), 429 (rate limited —
per IP and per (file, email)), 503 (no secret configured).
// Server-side (never expose the secret to the browser):
const res = await fetch(`${DRUPAL_INTERNAL_ORIGIN}/api/file-gate/mint`, {
method: 'POST',
headers: {
'Authorization': 'Basic ' + Buffer.from(':' + process.env.DRUPAL_FILE_GATE_SECRET).toString('base64'),
'Content-Type': 'application/json',
},
body: JSON.stringify({ media: mediaUuid }),
});
const { path } = await res.json();
// Hand the browser the public URL to redeem:
const downloadUrl = `${DRUPAL_PUBLIC_FILE_ORIGIN}${path}`;The metadata the front end needs (that a gated document exists, its name, type, size) should be exposed via your API (JSON:API/GraphQL) without a directly resolvable file URL — the bytes are delivered only through the gated endpoint.
file_gate_mcp exposes Tool API plugins
so an operator or a governed agent can ask about the gate over MCP. It depends
on Tool API and MCP Sentinel.
The base module depends on neither. The submodule declares Drupal ^11.4 only.
Tool API declares ^10.5 || ^11, so the submodule stays off Drupal 12.
| Tool | Operation | Returns |
|---|---|---|
file_gate_status |
read | base-module runtime findings, whether a signing secret is configured, named secret ids and whether each has a field scope, every gated field with its method, scheme and a protected flag |
file_gate_file_gate |
read | For one file or media UUID: whether it is gated, by which field and method, and that the plain /system/files URL will not serve it |
file_gate_grants_list |
read | Live usage-limited grants for one gated field, newest first, at most 200 |
file_gate_metrics |
read | Mints, deliveries, denials and authentication failures for 1 to 90 days |
file_gate_grant_revoke |
write | Revokes one grant by field and grant id |
The lookup tool answers a question API clients hit often: an upload or an entity
read returns a /system/files/… URL, and for a gated file that URL answers 403
for every account without bypass file gate. The tool says so, and says that a
trusted front end has to mint a grant.
- Grant
use file gate mcp toolsto the role your MCP credential uses. Revoke also needsrevoke file gate grants via mcp. Both are restricted permissions. - MCP Sentinel's gates apply first: permission, source readiness, scope, IP policy and rate limit. A tool is listed only for an account that can run it.
- No tool returns secret material, a file path, a URL, a grant token or a gate method's settings. Secrets appear by id only. A grant id identifies a grant for revocation; it cannot be redeemed.
- The grant tools act with site-operator reach. The HTTP routes bind a named secret to the grants it minted; these tools list and revoke grants minted by any secret. Grant the permissions with that in mind.
- Revoke is scoped by field: the field stored with the grant must match, so a
grant id from another field is refused. It revokes one grant per call, keeps
the kill mark for at least as long as the grant would have lived, and writes
the same
revokeaudit entry as the HTTP route, withsecret_id: mcpand the acting uid. - The grants list says whether a grant is bound to a subject. It does not return the stored subject hash, which is an unkeyed hash of a value the minting front end supplied.
- A media UUID resolves only when the acting account may view that media. A file UUID resolves for any holder of the permission, because core denies view on every gated file entity; the answer reveals that the file exists.
- A refusal from this module is one fixed message. Tool API and MCP Sentinel have their own messages for invalid input, denied access and rate limits. None of them relays an input value, and exception text is not logged.
- Results are capped at 128 KiB, or at the profile's response-size cap when that is lower.
- Installing the submodule publishes nothing by itself. Enable the tools in your site's MCP tool bridge configuration.
Not available as tools, by design: minting a download URL, serving bytes, issuing an OTP, bulk revoke, editing secrets or scopes, and switching gating on or off. A mint hands out a bearer URL and belongs to a trusted front end that knows who is asking.
- Deny by default. The
hook_file_download()implementation returns-1(a hard veto that overrides core's permissive private-file access) for any gated file requested at/system/files, unless the account holds Bypass file gate. It never grants there — delivery happens only on the module's route. - Signed grants.
sig = HMAC-SHA256(normalized_uri . "|" . canonical(claims), secret). Every claim (expiry, not-before, usage token/cap) is bound, so a client cannot change the file, the expiry, or the usage limit without invalidating the grant. Comparison is constant-time. - Fail closed. No secret ⇒ nothing validates and minting refuses (
503). - No path disclosure. The signed URL carries the file UUID, not the
private://path; delivery streamsBinaryFileResponsewithCache-Control: private, no-store. - Usage limits are approximate. The redemption counter is a fast key/value store, not a lock; a tight race could allow one extra redemption. Adequate for lead-gen and casual limits, not for hard licensing.
Mint authorizes nothing. It authenticates the caller with the shared secret and then mints whatever was asked for. It performs no entity access check and no field access check. The only content test anywhere in the path is that a media host must be published; minting by file UUID does not check even that.
So the secret's blast radius is the entire gated corpus. Anyone holding it can mint a working download URL for any gated file whose UUID they can guess or obtain, regardless of who they are or whether they could view the referencing entity.
That is a deliberate design — File Gate delegates the gate decision to a trusted back end that has already run its own (a lead form, a login, an entitlement check) — but it has two consequences worth stating plainly:
- Never provision the secret into a public web tier. If a front-end container that serves anonymous traffic holds it, that tier holds a skeleton key to every gated file. Mint from a server-side route or a back-end service, not from code that ships to the browser or from an environment an attacker reaching the front end can read.
- The secret is worth rotating like a credential, because it is one. It also doubles as the HMAC signing key, so rotating it invalidates outstanding grants — which is the point when you suspect exposure.
Mint trusts the secret holder. Identity-aware mint (account / uid, field
require_identity_mint, or global require_acting_account) and field-scoped
named secrets (secret_scopes) are opt-in constraints on that holder —
they are not a default ACL. Without them, anyone who holds the secret can
mint any gated file.
Gating only applies to files on the private file system. Public files are served straight off disk by the web server or a CDN and never reach Drupal, so there is no request to gate.
A field storage with file_gate.gated: true and a uri_scheme other than
private claims to be gated and is not. The module refuses every write that
would produce it:
- The field edit form forces the private scheme when you enable gating.
- An entity save (the entity API, a recipe, an update hook, a module's
default configuration) throws
GatedPublicSchemeExceptionbefore anything is written. The message names the storage and says how to fix it. - A raw configuration write (
drush config:set, a configuration tool that saves through the config factory) never loads the entity. Core has no event before such a write, so the module puts the previous value back and the command fails with the same exception. - A configuration import is rejected at validation, before any change.
- The configuration schema carries the rule as the
FileGateGatedFieldSchemeconstraint, so code that validates configuration gets a violation atthird_party_settings.file_gate.gated.
All five use one rule, GatedFieldSchemeRule. A field type with no uri_scheme
setting is not affected.
A site that is already in this state keeps working. The field loads, the edit
form opens, and a save that leaves gated and uri_scheme as they were is
allowed, so a core update, an unrelated configuration import or a module
uninstall does not start failing. The status report shows the field as an error
until it is fixed. Fix it by saving the field with the private scheme or by
removing the gating. Moving it to another non-private scheme is refused.
Changing an existing field to the private scheme does not move files that are already stored publicly. They stay where they are, and stay readable, until they are re-uploaded or migrated.
A gate method answers "has this request passed the gate for this file?"
namespace Drupal\my_module\Plugin\GateMethod;
use Drupal\Core\StringTranslation\TranslatableMarkup;
use Drupal\file\FileInterface;
use Drupal\file_gate\Attribute\GateMethod;
use Drupal\file_gate\GateMethodBase;
use Symfony\Component\HttpFoundation\Request;
#[GateMethod(
id: 'my_method',
label: new TranslatableMarkup('My method'),
description: new TranslatableMarkup('…'),
)]
final class MyMethod extends GateMethodBase {
public function grants(FileInterface $file, Request $request): bool {
// Return TRUE only if the request positively proves it passed the gate.
}
public function mint(FileInterface $file): ?array {
// Return query params to append to the download URL, or NULL if this
// method decides access live (no pre-issued grant).
}
}Five methods ship in the core module; three more ship in optional submodules (each noted below). All are complete — new methods are added by third parties or future submodules, not tracked as a roadmap here.
| Method | Status | Notes |
|---|---|---|
signed_url |
shipped | HMAC signed URL; TTL, availability window, usage limits. |
authenticated |
shipped | Delivers to any logged-in Drupal user. |
token |
shipped | Revocable per-grant token and/or a pre-shared campaign allowlist. |
form |
shipped | Coupled email / lead-capture form (Drupal renders the gate). Ships in the File Gate Form submodule. |
otp |
shipped | One-time passcode emailed to a self-identified address; single-use, TTL-limited, attempt-locked. |
referrer_lock |
shipped | Signed URL that is only redeemable from an allowed origin/referrer (hardening, not authz). |
assurance |
shipped | Signed URL gated on a hardware-backed OIDC assurance (PIV/CAC + FIDO2/WebAuthn), with opt-in DPoP. Ships in the File Gate Assurance submodule. |
commerce |
shipped | Gate behind a purchase / entitlement (Drupal Commerce by default, or a pluggable external checker). Ships in the File Gate Commerce submodule. |
- Administer File Gate — configure the module and view gated fields.
- Bypass file gate — download gated files directly via
/system/files(editors/operators). The administrator role holds it implicitly.
File Gate logs to its own file_gate channel: warning for security events
(denied downloads, failed mint auth, fail-closed refusals) and info for usage
events (mints, deliveries). Watch it via Reports → Recent log messages or your
log aggregator.
- Jeremy Michael Cerda (jmcerda)
- Sponsored by Wilkes & Liberty, LLC — https://wilkesliberty.com
GPL-2.0-or-later. See LICENSE.