Skip to main content
Developer System administrator Jahia 8.2

How can I keep a Jahia site publicly reachable while restricting authoring and administration to a trusted network?

Question

How can I keep a Jahia site publicly reachable while restricting authoring and administration to a trusted network?

Answer

A reverse-proxy rule is a URL filter, not authorization. It reduces the surface an anonymous internet client can reach. It cannot constrain someone who is on the trusted network, it cannot neutralise a stolen session cookie, and it is not a substitute for patching or for Jahia's own security settings.

Treat it as a second independent layer in front of Jahia's authentication, never as a replacement for it.

Applies to Jahia 8.2.3.2. The path behaviour in sections 1 and 4 was measured on that version; the proxy configuration in section 5 was measured on HAProxy 3.2.25. Where a claim was not measured, the sources comment at the end of this entry says so explicitly.

1. Know which paths are back office

These are the path families to restrict. The right-hand column is what a stock 8.2.3.2 actually answered, probed anonymously and again as root:

Path familyWhat it isMeasured on 8.2.3.2
/jahia/Back office (jContent, administration)401 anon, 200 authenticated
/cms/edit/, /cms/editframe/Edit mode401 anon
/cms/contribute/, /cms/contributemode/Contribute mode401 anon
/cms/studio/, /studio/Studio401 anon
/administration/, /cms/administrationAdministration401 anon, 302 authenticated
/tools/, /modules/tools/, /tools/osgi/Tools, including the Groovy console and the OSGi web console302 to login anon
/start, /welcome, /welcome/*WelcomeServlet401 anon, serving a login page
/welcome/adminmodeAdministration mode entry point500 anon, returning a 35 KB error page
/server/Module-provided404 on a bare install; module dependent
/modules/api/JCR REST API405 to GET
/modules/graphqlGraphQL400 to GET; it is POST-only
/modules/graphqlwsGraphQL over websocketmodule-provided
/modules/provisioning, /modules/modulemanagerProvisioning, module managermodule-provided
/repository/, /dav/WebDAV mount points404 to GET; 501 to every WebDAV verb on a bare install - see the warning
/cms/export/, /cms/importContent export and import401 anon
/cms/find*Principal and user lookupnot distinguishable on a bare 8.2.3.2 - see the note below the table
/cms/render/default/Render in the edit workspace404 anon, 200 authenticated
/files/default/File delivery from the edit workspace404 anon, 200 with the file authenticated
/files/preview/File delivery, preview workspace404 - no such workspace on 8.2.3.2
/gwt/, *.gwt, /engines/Legacy back-office plumbing. *.gwt is a suffix mapping - see below404 on a bare install; module dependent

On /cms/find*, be careful what you claim. An earlier version of this entry said these endpoints enumerate the user directory. That was not measured and is not stated here. On a bare 8.2.3.2, /cms/findPrincipal, /cms/findUser, /cms/findGroup and /cms/findUsersAndGroups all return a byte-identical 1396-byte 400, the same as /cms/thisDoesNotExist; the .do spelling returns 302 to /error.html, and so does /cms/totallyMadeUp.do. So nothing on a bare install distinguishes them from a path that does not exist. They are listed because they are a coherent back-office namespace that modules populate, which is reason enough to restrict the family - not because anything here proved what they do.

Two consequences for the rule. Match them by prefix, /cms/find, not by exact name: an exact match misses /cms/findPrincipal.do, misses findUsersAndGroupsInAcl, and misses whatever the next module adds. And note that a bare prefix is the right tool here precisely because the whole namespace below it is privileged - which is not true of /files/ or /generated-resources/, and is why those get workspace-specific prefixes instead.

These stay public, and breaking them breaks the site:

  • /files/live/…: public file delivery. Measured: 404 before publication, 200 anonymously once the file is published. A rule matching /files/ wholesale breaks every published image and document on the site - restrict the workspace-specific prefixes, never the servlet.
  • /generated-resources/…: the aggregated CSS and JavaScript of the public site. Measured: the real asset returns 200 and 14 KB of text/css anonymously, while an invented name under the same prefix returns 404 - which is the control showing the 200 is the servlet serving, not a generic answer. Same shape as /files/: the prefix looks like plumbing and is not.
  • /cms/login: 200 anonymously. If your editors reach the back office through the public hostname, blocking login locks them out. If they do not, see the note on /start below.

/cms/render/live/… is the interesting case, because the obvious assumption about it is wrong. It renders the public site, so it looks untouchable. But seo-urlrewrite.xml, shipped at /usr/local/tomcat/webapps/ROOT/WEB-INF/etc/config/, carries outbound rules that strip /cms/render/live/ from every link Jahia emits, and an inbound rule that puts it back:

<from>^/cms/([^\?]*)(\?.*)?$</from>
<to last="true">/cms/render/live/$1</to>

urlRewriteSeoRulesEnabled and urlRewriteRemoveCmsPrefix both default to true. Measured on two independent stock 8.2.3.2 containers: a live page emits href="http://host/sites/systemsite/home.html" and zero hrefs containing /cms/. So on a default deployment the public site does not use /cms/ at all, and restricting the whole /cms/ tree is viable.

That is a decision to make on evidence, not on either assumption. Before restricting /cms/ wholesale, confirm on your install that the SEO rules are enabled and that no content hard-codes a /cms/ link - a single editor-authored absolute URL is enough to break a page. If you cannot confirm it, restrict the specific /cms/ families in the table and leave the rest alone, which is what the worked example in section 5 does.

Finding the list for your own install

The table above is a starting point, not a closed set. The authoritative list of what Jahia routes is WEB-INF/web.xml in the deployed ROOT webapp:

docker exec <container> grep -A2 '<servlet-mapping>' \
  /usr/local/tomcat/webapps/ROOT/WEB-INF/web.xml

Reading it on 8.2.3.2 turns up three things a path list tends to miss:

  • A suffix mapping. *.gwt is mapped to the GWT dispatcher servlet, so a request ending in .gwt is routed there from any prefix, not only from under /gwt/. A prefix rule cannot express that; you need a suffix match. This is read from web.xml, not measured: /sites/systemsite/home.gwt, /sites/systemsite/home.zzz and /sites/systemsite/homeXgwt all return the same byte-identical 1290-byte 404 page, so the status tells you nothing either way. The routing is in the mapping regardless of what the servlet then answers. Check your own modules for the same shape before assuming prefixes are enough.
  • Servlets neither this entry nor the usual lists mention, among them /atmosphere/ (500 anonymously, with a 31 KB error page), /flow/, /initializationCompleted/ and /validateTicket (both 200 anonymously with an empty body). Judge each one: /flow/ backs Jahia's own form flows and blocking it can break login, so this entry does not recommend restricting it.
  • Prefixes that must stay public, such as /generated-resources/ above. The file does not tell you which is which. Only a probe does.

The point is the method rather than the list: enumerate the mappings, then probe each one anonymously and authenticated, with a control.

A 404 does not mean the endpoint is absent, and a 200 to an OPTIONS does not mean it is present. /files/default/ answers 404 to an anonymous GET on 8.2.3.2 and 200 with the file to an authenticated one: the status tracks who is asking, not whether the path exists. In the other direction, /repository/, /dav/ and /files/ all answer 200 to an OPTIONS while returning Jahia's own 404 page to a GET - but that 200 is the servlet container answering OPTIONS generically. Measured on a bare 8.2.3.2, every WebDAV verb (PROPFIND, MKCOL, COPY, MOVE, LOCK) returns 501, no DAV: compliance header is sent, and Allow: lists only ordinary HTTP verbs - so WebDAV is not implemented on that install. It is module-provided, and deploying the module makes the path live without anything you can see today changing. The same is true of several other paths above. Restrict the path family regardless of what your install answers.

2. Decide what "trusted" means, and where it usually goes wrong

If Jahia sits behind a CDN or any load balancer, the proxy no longer sees the client's real address, so the instinct is to read X-Forwarded-For. Which occurrence you read decides whether the restriction works at all.

X-Forwarded-For is built left to right, and each hop appends the address it saw. So the leftmost element is whatever the client chose to send: it is caller-controlled, always.

What you gate onResult
Leftmost X-Forwarded-ForNo protection. One extra header on an ordinary request is enough to be treated as trusted
Rightmost X-Forwarded-For, unguardedResists the header above, but any caller that reaches the proxy directly simply puts a trusted address last
The TCP source addressCannot be set by the caller, but only equals the real client when your proxy is the first hop

The workable rule: gate on the TCP source address, and only fall back to a forwarded header when the connection itself came from a hop you already trust. If you must read a forwarded header, read the occurrence your own infrastructure appended, never the leftmost, and treat the list of trusted hops as an authentication boundary, because that is exactly what it is. Everything inside that range can assert an identity.

The origin lock

Gating on the source address closes one hole and leaves a larger one open. If the origin is reachable by address, then every rule you wrote at the CDN (its WAF, its rate limits, its header injection) is optional from the caller's point of view. Overriding DNS, or simply connecting to the origin IP, skips the entire tier.

Close it by requiring a shared secret that the CDN injects as an origin custom header, and refusing any request that arrives without it. Two properties are what make it work, and both are easy to get wrong:

  • It must be an origin custom header, not a forwarded header. CloudFront's origin custom headers are attached by the CDN on the way to the origin and override whatever the viewer sent, so a caller cannot supply the value themselves. A header you merely forward is caller-controlled and locks nothing. Other CDNs have an equivalent; check that yours overrides rather than merges.
  • The CDN must be the only way in. Keep a narrow exemption for traffic that legitimately reaches the origin directly (a platform health probe is the usual one), and point everything else at the CDN. An external monitor or a cache warmup job that talks to the origin by address will start getting refused, and that is the rule working.

Section 5 assembles the whole thing.

3. Decide what to answer

  • For humans, redirect. An editor who follows an old bookmark should land on the authoring hostname, not a wall.
  • For machine endpoints, refuse flatly. A redirect is the wrong answer for an API client, and sending it puts your internal authoring hostname into scripts and logs. 404 also says less about your deployment than 403 does.

Watch out for paths an ordinary logged-in visitor legitimately uses: /cms/dashboard and /cms/mysettings are reachable by any authenticated member. Redirecting those to an authoring host they are not allowed to reach just hands them a 403.

4. Normalise before you match

This is the part most rules get wrong. Your proxy matches the raw request path; the servlet container routes on the decoded, normalised one. Wherever the two disagree about what the URL is, your rule and the application are looking at different strings, and the rule is the one that misses.

Measured on 8.2.3.2, all of these reach the same handler as /tools/:

/tools/              302 -> /modules/tools/      the canonical spelling
/TOOLS/              302 -> /modules/tools/      case
/ToOlS/              302 -> /modules/tools/      mixed case
/%74ools/            302 -> /modules/tools/      percent-encoded
/%74%6f%6f%6c%73/    302 -> /modules/tools/      fully percent-encoded

Be precise about what this means. Jahia authenticates every one of those spellings: requested anonymously they all end at /cms/login, exactly as /tools/ does. Jahia is doing its job. What the variants defeat is your rule. A proxy matching the literal string /tools/ does not match /TOOLS/, so for that spelling the restriction you added simply is not applied and you are relying on the application by itself, which is the situation you added the rule to get out of.

So, before matching, URL-decode (more than once), strip matrix parameters, collapse repeated slashes, and lowercase. Then match against the result.

Dot segments and encoded percent signs are best refused outright rather than canonicalised: correct canonicalisation is harder than rejection, and neither has a legitimate use on a back-office path. On 8.2.3.2 Tomcat itself already refused /foo/../tools/, /tools;jsessionid=x/, /tools%2f and /modules/%2574ools/ with 400 or 404, but that is servlet-container configuration, it is not yours to guarantee on every deployment, and a rule that depends on it is a rule you cannot reason about. Refuse them at the edge too.

5. A worked example

HAProxy, in a frontend. Four ideas, in this order: normalise the path, derive trust from the socket, require the CDN's secret, then match. They port directly to nginx or Apache.

Read it top to bottom. In HAProxy the order of http-request lines is the order of evaluation, and several of the mistakes called out below are ordering mistakes rather than logic mistakes.

5.1 Normalise, and declare the gates

# one normalised copy of the path, matched by every rule below
    http-request set-var(txn.p) path,url_dec,url_dec,regsub(;[^/]*,,g),regsub(/+,/,g),lower

    # GATE 1 - the real socket peer. No header can change this.
    acl from_trusted src 192.0.2.0/24

    # GATE 2 - the secret the CDN injects.  openssl rand -hex 32
    acl cf_verify req.hdr(X-Origin-Verify) -m str REPLACE_WITH_A_LONG_RANDOM_SECRET

    # the hostnames this frontend governs
    acl host_public    hdr(host),field(1,:),lower -m str www.example.com
    acl host_authoring hdr(host),field(1,:),lower -m str authoring.example.com

    # Carry that decision into a variable, because the RESPONSE rules in 5.7
    # cannot test it. `hdr(host)` in a response rule reads the response headers,
    # which have no Host - HAProxy warns "will never match" and the rule
    # silently does nothing. A txn variable set here survives into the response
    # phase, which is the only thing that does.
    http-request set-var(txn.public) str(no)
    http-request set-var(txn.public) str(yes) if host_public

    # traffic that legitimately arrives WITHOUT the secret.
    # Jahia answers /ping.jsp itself, which is why platforms use it as a liveness
    # probe - and that probe connects to the origin by address, so it never
    # transits the CDN and never carries the header. Without this exemption the
    # origin lock answers 403 and the platform marks the node down.
    acl is_liveness var(txn.p) -m str /ping.jsp

    # a switch, so the same file can ship to an environment with no CDN in front.
    # HAProxy cannot define one acl in terms of another, so this is a plain
    # always_true rather than `acl require_origin_secret host_public`.
    acl require_origin_secret always_true
    # acl require_origin_secret always_false    # <- dev: no CDN in front

5.2 A diagnostic that names the gate that refused

Worth writing before you need it, and worth placing here, above everything it reports on, for the reasons below. It names which gate said no, and it never echoes the secret, only whether it matched. Note that it sets every variable it prints: a probe that reads a variable the rest of your config happens to populate is a probe that renders blank the moment you reuse it somewhere else.

# http-request set-var(txn.dbg_trusted) str(no)
    # http-request set-var(txn.dbg_trusted) str(yes) if from_trusted
    # http-request set-var(txn.dbg_secret) str(no)
    # http-request set-var(txn.dbg_secret) str(yes) if cf_verify
    # http-request return status 200 content-type "text/plain" lf-string "src=%[src] host=%[req.hdr(host)] path=%[var(txn.p)] trusted=%[var(txn.dbg_trusted)] secret_ok=%[var(txn.dbg_secret)]" if host_public { path /edge-check }

Placement decides whether it tells you anything:

  • After every acl it names. HAProxy resolves ACL names at parse time, so referencing cf_verify above its own acl line is a config error (the proxy refuses to start), not a runtime miss.
  • Before the front-door denies of 5.3. Any lower and those denies answer first, so the probe itself returns 403 and tells you nothing. Measured: placed last, a request to the probe without the secret returns 403 rather than a report.
  • Before the del-header of 5.5. Below it, the probe tests a header that has already been removed and can only ever answer secret_ok=no. Measured: placed last, a request carrying the correct secret still reports secret_ok=no. That misreading cost real time on the deployment this entry generalises from.

Then scope it to one hostname and remove it afterwards. Unscoped, it answers on every Host the frontend serves and hands your internal addresses to anyone who asks.

5.3 The front door

# The authoring hostname is trusted-network-only, full stop. No CDN secret is
    # accepted here: the CDN never fronts this name, so a request carrying one is
    # either misrouted or forged.
    http-request deny deny_status 403 if host_authoring !from_trusted !is_liveness

    # The public hostname must arrive through the CDN. Operators on the trusted
    # network may still browse it directly - they simply get the public
    # experience, which is the point of keeping the two hostnames separate.
    http-request deny deny_status 403 if host_public !from_trusted !cf_verify !is_liveness require_origin_secret

Giving each hostname exactly one response variant matters more than it looks. If the public hostname behaved differently for trusted callers, a cache in front of it would have two variants of one URL and no reliable way to choose between them.

5.4 Refuse the confusing spellings, then match paths

# refuse the spellings that exist only to confuse a filter
    acl bad_dotseg var(txn.p) -m sub ../
    acl bad_dotseg var(txn.p) -m sub ./
    acl bad_pctpct path       -m sub %25
    http-request deny deny_status 400 if host_public bad_dotseg
    http-request deny deny_status 400 if host_public bad_pctpct

    # EVERY family is declared TWICE: an exact match for the bare path, and a
    # /-terminated prefix for everything beneath it. `-m beg /tools/` on its own
    # does NOT match a request for /tools, which then reaches the application
    # untouched - measured, not theoretical. Repeating an acl NAME is an OR,
    # which is what keeps this readable; 5.6 relies on the same property.

    # humans get sent to the authoring host
    acl p_backoffice var(txn.p) -m str /jahia /administration /cms/administration /studio
    acl p_backoffice var(txn.p) -m beg /jahia/ /administration/ /studio/
    acl p_backoffice var(txn.p) -m beg /cms/edit/ /cms/editframe/ /cms/adminframe/
    acl p_backoffice var(txn.p) -m beg /cms/contribute/ /cms/contributemode/
    acl p_backoffice var(txn.p) -m beg /cms/studio/ /cms/studiovisual/
    http-request redirect prefix https://authoring.example.com code 302 if host_public !from_trusted p_backoffice

    # machine surfaces are refused flatly. /gwt/ and /engines/ belong HERE and
    # not above: redirecting a back-office RPC call to another hostname does not
    # help the caller, it only breaks it somewhere else.
    acl p_api var(txn.p) -m str /modules/api /modules/provisioning /modules/modulemanager
    acl p_api var(txn.p) -m str /modules/graphqlws /repository /dav /cms/export /cms/import
    acl p_api var(txn.p) -m beg /modules/api/ /modules/provisioning/ /modules/modulemanager/
    acl p_api var(txn.p) -m beg /modules/graphqlws /repository/ /dav/ /cms/export/
    acl p_api var(txn.p) -m beg /cms/import /gwt/ /engines/
    acl p_tools var(txn.p) -m str /tools /modules/tools /cms/tools
    acl p_tools var(txn.p) -m beg /tools/ /modules/tools/ /cms/tools/
    # NOTE the lowercase spelling: txn.p was lowercased in 5.1, so an ACL
    # value with a capital in it can never match. /validateTicket is the
    # trap - it is the one path here whose real spelling is mixed case.
    acl p_api var(txn.p) -m str /server /validateticket
    acl p_api var(txn.p) -m beg /server/

    # Administration mode, reached through the WelcomeServlet. Restricting the
    # whole /welcome family and /start is the stricter posture and it puts
    # LOGIN ITSELF on the trusted network; keep only the adminmode line if your
    # editors sign in through the public hostname. Pick one deliberately -
    # leaving both commented out is a decision too.
    acl p_welcome var(txn.p) -m str /welcome/adminmode
    acl p_welcome var(txn.p) -m beg /welcome/adminmode/
    # acl p_welcome var(txn.p) -m str /start /welcome
    # acl p_welcome var(txn.p) -m beg /welcome/

    # A SUFFIX mapping: *.gwt reaches the GWT dispatcher from any prefix, so no
    # amount of prefix matching covers it. Read from web.xml on 8.2.3.2.
    acl p_suffix var(txn.p) -m end .gwt
    # /cms/find is matched as a BARE prefix, deliberately. An exact match plus
    # /cms/find/ would miss /cms/findPrincipal.do and findUsersAndGroupsInAcl.
    # A bare prefix is safe here only because the whole namespace below it is
    # privileged - do not copy this shape onto /files/ or /generated-resources/.
    acl p_find var(txn.p) -m beg /cms/find

    # the unpublished workspaces - RENDERED pages and FILE delivery. The second
    # is the one that gets forgotten: restricting /cms/render/default/ while
    # leaving /files/default/ open still serves unpublished binaries to anyone
    # holding a back-office credential (measured: 200 with the file, as root).
    # NOTE /files/live/ is deliberately NOT here - it serves the published
    # assets of the public site. /files/preview/ resolves to nothing on 8.2.3.2
    # (the only JCR workspaces are default and live); it is kept as defence in
    # depth, not because it answers today.
    acl p_unpublished var(txn.p) -m beg /cms/render/default/ /cms/render/preview/
    acl p_unpublished var(txn.p) -m beg /files/default/ /files/preview/

    http-request deny deny_status 404 if host_public !from_trusted p_api
    http-request deny deny_status 404 if host_public !from_trusted p_tools
    http-request deny deny_status 404 if host_public !from_trusted p_find
    http-request deny deny_status 404 if host_public !from_trusted p_welcome
    http-request deny deny_status 404 if host_public !from_trusted p_suffix
    http-request deny deny_status 404 if host_public !from_trusted p_unpublished

192.0.2.0/24 is the RFC 5737 documentation range; substitute your own.

5.5 Request header hygiene

Two deletes. The second matters more than it looks.

# Never let the origin secret reach the application. Unconditional, NOT
    # `if host_public`: one CDN distribution often fronts several hostnames,
    # including ones this frontend does not otherwise govern, and a host-scoped
    # delete would forward your own secret to the back end on exactly the
    # hostname you were not thinking about.
    http-request del-header X-Origin-Verify

    # Strip credentials on the public path.
    http-request del-header Authorization       if host_public
    http-request del-header Proxy-Authorization if host_public

Jahia accepts Basic credentials, JWTs and personal API tokens on the same Authorization header, and it authenticates on any request rather than only on a login endpoint. GET /en/home.html carrying credentials renders as that user. None of the path rules above help: the caller never needs to touch /jahia/ at all.

Confirm one thing before shipping this: the public site must authenticate with a session cookie, not with the Authorization header. If any public API on that hostname takes a Bearer token, stripping it breaks that API. Search the modules your public site actually runs for Authorization before deciding. Where it is unused, this removes an entire authentication surface at no functional cost.

Strip rather than deny, deliberately. Stripping makes the request anonymous, which is the intent. Denying returns 403 for a URL whose CDN cache key may not include Authorization - one caller can then put that 403 in the shared cache for everyone. If you would rather fail loudly, deny and give the CDN a cache policy that includes the header.

5.6 Method floor

A ceiling on verbs is cheaper than a list of paths, and it covers the endpoints you forgot to list.

acl method_public method GET HEAD POST
    http-request deny deny_status 405 if host_public !method_public

    # GraphQL is POST-only. Note the exact match PLUS the /-terminated prefix:
    # `-m beg /modules/graphql` on its own also matches /modules/graphqlws, and
    # the looser rule sitting first would answer for it.
    acl is_graphql var(txn.p) -m str /modules/graphql
    acl is_graphql var(txn.p) -m beg /modules/graphql/
    http-request deny deny_status 405 if host_public is_graphql !{ method POST }

It sits after the path rules deliberately. A wrong-method request to a restricted path is then refused by the path rule and answers 404 like every other request to that path; put the method floor first and the same request answers 405, which confirms the path exists. Requests to paths you do serve still get 405, which is the correct answer there.

One line removes PUT, DELETE, PATCH, PROPFIND, MKCOL and the rest of the WebDAV verb set. That is what actually closes the endpoints from the warning in section 1 - the ones answering 404 to a GET and 200 to an OPTIONS - without depending on having listed every path they are mounted on.

5.7 Headers on every response, including the ones HAProxy writes itself

http-response rules run only on responses that came back from a server. Everything this config refuses - the 403 front door, the 404 path rules, the 405 method floor - is generated by HAProxy and never passes through them.

Measured on HAProxy 3.2.25, setting one header three ways:

Rule used200 from the back end404 written by HAProxy
http-response set-headerpresentabsent
http-after-response set-headerpresentpresent
both togetherpresent oncepresent once

So http-after-response alone does the whole job, and the parallel http-response list many configs carry is redundant:

http-after-response set-header Strict-Transport-Security "max-age=31536000; includeSubDomains" if { var(txn.public) -m str yes }
    http-after-response set-header X-Content-Type-Options nosniff if { var(txn.public) -m str yes }
    http-after-response set-header Referrer-Policy strict-origin-when-cross-origin if { var(txn.public) -m str yes }
    http-after-response set-header X-Frame-Options DENY if { var(txn.public) -m str yes }
    http-after-response del-header X-Powered-By if { var(txn.public) -m str yes }

Four details worth getting right:

  • set-header, not add-header. set- replaces, add- appends. Measured: with add-header on both rule sets, a back-end response carries the header twice; set-header on both carries it once.
  • Scope on a variable, not on host_public. A response rule cannot read the request's Host. Writing if host_public here parses, runs, and never matches - HAProxy says so at startup with "acl 'host_public' will never match because it only involves keywords that are incompatible with 'frontend http-response header rule'". Read your startup warnings; that one is the difference between these headers being set and not.
  • Scope it to hostnames you own. These change what every response on that name says. If the same proxy fronts a site another team owns, setting them unscoped is not your call to make.
  • No preload on HSTS. Preload declares that the registrable domain and every subdomain is HTTPS-only indefinitely. Asserting that from one hostname is at best inert and at worst a commitment the owners of the sibling names never made. max-age with includeSubDomains gives the protection without the cross-team claim.
  • http-after-response needs HAProxy 2.2 or later. On 2.0 and 2.1 fall back to http-response and accept that generated responses go out bare - or upgrade, because that gap is the whole point of this section.

5.8 Rotating the secret

Repeated acl lines of the same name are OR'd, which gives you a clean overlap:

  1. Add a second cf_verify line carrying the new value, reload. Both are now accepted.
  2. Update the CDN's origin custom header to the new value.
  3. Remove the old cf_verify line, reload.

There is never a window in which a legitimate request is refused.

Practical warnings

  • A prefix can collide with a sibling. -m beg /modules/graphql also matches /modules/graphqlws. If the first rule answers differently from the second, the wrong one wins and you have leaked that the endpoint exists. Use an exact match plus a /-terminated prefix when one path is a prefix of another - 5.6 does exactly that, and 5.4 does it for every family, because -m beg /tools/ alone does not match a request for /tools.
  • Every rule carries an explicit host condition. If one proxy fronts several hostnames, a rule without one silently applies to all of them.
  • This example is scoped, not deny-by-default, so a hostname you did not name gets nothing. A request for a third hostname reaches the back end with no origin lock, no source gate and none of the path rules. That is the right default when one proxy fronts sites that have not opted in, but it means adding a site is an explicit act. If every hostname your proxy answers on should be governed, refuse unrecognised ones instead of relying on this.
  • If you paste this into a hosted configuration UI, avoid backslashes. Line continuations and escape sequences such as \n have been seen to be interpreted by the form before the proxy ever parses them, splitting one directive across several lines and failing the reload with errors that point at the wrong place. Everything above is deliberately backslash-free and fits on single lines for that reason. If a long line still gets mangled, split the rule into several short ones rather than continuing it.

6. Prove it, from outside

Write the checks down and run them from a genuinely untrusted network, not from the office, and not from the VPN you just allowed. Measuring from inside the trusted network measures the wrong path and will tell you everything is fine.

At minimum, assert that from outside:

  • each restricted path answers the status you intended, and so does its uppercase, percent-encoded and trailing-slash-variant spelling;
  • /cms/render/live/… and an ordinary page still answer 200;
  • /cms/login still answers 200, if your editors need it;
  • the same requests from the trusted network still reach the back end;
  • nothing leaked onto a different hostname the same proxy serves.

Two techniques make the difference between a verification and a ritual:

  • Compare more than the status code. Your proxy's built-in error pages are small and byte-exact, while an application error page is a full styled document an order of magnitude larger. The application also answers 404 on a path it does not serve, so a check comparing status codes alone passes identically whether or not your config is deployed. Compare the response body size too, and you are testing the edge rather than hoping.
  • The log already names the culprit. In HAProxy's default log format a denial written by your own config shows no server and a proxy termination state (<NOSRV> with PR--). A 403 that came from Jahia shows a real backend and server name with ----. You do not need a custom log format to tell the two apart.

And once the origin lock of 5.3 is on, add one more check: request the public hostname without the secret header, straight at the origin address. It must be refused. That single request is the whole point of the lock, and it is the one nobody runs.

7. What this does not solve

Worth stating to whoever signs off on the change:

  • It cannot constrain anyone already on the trusted network.
  • It cannot invalidate a session cookie that was minted legitimately and replayed.
  • A per-IP rate limit only counts per IP; it does not recognise the same actor arriving from many addresses. Account lockout belongs in the application.
  • Parameters are not inspected. An endpoint you do allow is reachable with any query string or request body, so the application remains responsible for what it accepts.
  • It is not a substitute for keeping Jahia up to date, nor for Jahia's own settings such as secured file upload, the security profile, CSP and HTML filtering.

This article was drafted with AI assistance, then reviewed and curated by Jahia Customer Support engineers before publication.