A default GeoServer or MapServer deployment is a read-anything, write-if-you-guess-the-password service with an administration API on the same port as the map endpoints. Securing it is four separate concerns — transport, identity, authorisation and cost — and they fail independently. This guide covers what a public OGC endpoint exposes by default, how to hold credentials for automation without embedding them, and why the control that prevents an outage is not an authentication control.
This assumes a service published behind a reverse proxy, which is where most of these controls belong. GeoServer’s own security subsystem handles identity and per-layer authorisation well; it is not the right place for rate limiting or request size caps, because by the time GeoServer evaluates a rule it has already accepted the connection and parsed the request.
The single most consequential architectural decision is separating the administration surface from the data surface. The REST catalog API — the one every automation guide here uses — can create stores, rewrite styles and delete layers. It lives under /rest on the same port as /wms, and a deployment that publishes one publishes the other. Putting the administration paths on a separate listener reachable only from the deployment network is worth more than any credential policy, because it removes the surface rather than guarding it.
Transport is listed first deliberately. HTTP Basic authentication transmits a reusable credential, base64-encoded, on every request. Over plain HTTP that is a password in clear text on the wire and in every intermediate log. There is no configuration of the layers above that compensates.
OGC operations differ enormously in what they permit, and treating them uniformly is how services end up either unusably locked down or open in the one place that matters.
GetCapabilities is a directory of everything published. It is usually the intended public face, and it is also a complete map of your layer names, attributes, reference systems and extents — useful reconnaissance if some of those layers are meant to be internal. GeoServer filters capabilities by the caller’s permissions, which is the correct behaviour and worth verifying rather than assuming.
GetMap and GetTile return rendered pixels. Their risk is not disclosure but cost: an unbounded pixel count can allocate hundreds of megabytes per request, as the heap sizing arithmetic shows.
GetFeature and OGC API - Features item requests return the underlying data. Without a count cap they are a bulk export endpoint — which may be exactly what you intend for open data, and is emphatically not what you intend for a layer containing personal information. This is the operation where authorisation actually matters.
Transaction — WFS-T — modifies data, and a Delete with an empty filter removes every feature of a type. It should never be reachable by an anonymous caller, and the writer role should be distinct from the reader role rather than being the administrator account used by convenience.
Automation needs credentials. The question is where they live between the secret store and the request, and the answer that survives contact with a repository is: resolved at call time, never written to disk, never in a default argument.
from __future__ import annotations
import os
from dataclasses import dataclass
from typing import Callable
import requests
from requests.auth import HTTPBasicAuth
class MissingCredential(RuntimeError):
"""Raised at use time, naming the variable that was not set."""
@dataclass
class Credential:
"""A secret resolved on demand, never stored on the instance.
Holding the resolver rather than the value means the secret is not in
a repr, not in a pickle, and not in a traceback frame that outlives
the call. It also means rotation takes effect without a restart.
"""
name: str
resolver: Callable[[], str | None]
def get(self) -> str:
value = self.resolver()
if not value:
raise MissingCredential(f"{self.name} is required but not set")
return value
def __repr__(self) -> str: # keep it out of logs and tracebacks
return f"Credential({self.name!r}, <resolver>)"
def from_env(variable: str) -> Credential:
return Credential(variable, lambda: os.environ.get(variable))
def from_file(path: str, name: str | None = None) -> Credential:
"""Read a mounted secret file — the container-native form."""
def read() -> str | None:
try:
with open(path, encoding="utf-8") as fh:
return fh.read().strip()
except OSError:
return None
return Credential(name or path, read)
@dataclass
class ServiceClient:
base: str
user: str
secret: Credential
verify_tls: bool = True
timeout: int = 60
def __post_init__(self) -> None:
if self.base.startswith("http://") and not self.base.startswith(
("http://localhost", "http://127.0.0.1")):
raise ValueError(
f"refusing Basic auth over plain HTTP to {self.base} — "
"the credential would travel in clear text")
def request(self, method: str, path: str, **kwargs) -> requests.Response:
resp = requests.request(
method, f"{self.base.rstrip('/')}{path}",
auth=HTTPBasicAuth(self.user, self.secret.get()),
verify=self.verify_tls, timeout=self.timeout, **kwargs)
# Never let a 401 body reach a log — some servers echo the request.
if resp.status_code == 401:
raise PermissionError(f"authentication failed for {self.user!r}")
resp.raise_for_status()
return resp
def redact(url: str) -> str:
"""Strip embedded credentials before a URL is logged."""
from urllib.parse import urlsplit, urlunsplit
parts = urlsplit(url)
if parts.username or parts.password:
netloc = parts.hostname or ""
if parts.port:
netloc = f"{netloc}:{parts.port}"
return urlunsplit((parts.scheme, netloc, parts.path, parts.query,
parts.fragment))
return url
Three properties make this worth the small amount of ceremony. The secret is never an attribute, so it cannot appear in a repr, a pickle or a traceback. Plain HTTP to a non-local host is refused rather than warned about, because a warning in a log is not a control. And a 401 raises before its body — which some servers helpfully echo the request into — reaches a logger.
A rotated credential should not need a restart. Because Credential resolves at call time, replacing the value in the environment or the mounted file takes effect on the next request. A client that reads the secret once at construction keeps using the old one until the process is replaced, which is how a rotation turns into an outage.
Exception reports disclose more than they should. A GeoServer ServiceException in its default configuration can include the underlying SQL error, which names tables and columns. For a public endpoint that is more than a client needs; configuring generic exception text with the detail retained in logs is the standard trade.
The REST API and the OGC endpoints share a port. A firewall rule permitting the map service permits the catalog API. Separating them onto different listeners, or blocking /rest at the proxy for external traffic, is the control that actually removes the exposure — see Securing GeoServer REST Credentials in Python Automation.
Store passwords travel in catalog exports. GeoServer’s REST catalog returns store definitions with the password field populated for an administrator. Any tooling that walks the catalog — including the export and restore guide — must strip them before writing anything to disk.
Anonymous read is a decision, not a default to inherit. Many deployments are genuinely open data services where anonymous read is correct. The failure is inheriting that posture for a layer that was never meant to be public, which is why per-layer rules should be explicit rather than relying on a permissive default.
Security posture is testable, and the tests belong in the same pipeline as the compliance checks. The valuable assertions are negative ones — that something is not reachable — because those are what silently regress when a configuration is edited.
import pytest
import requests
PUBLIC = "https://maps.example.org/geoserver"
def test_rest_api_is_not_publicly_reachable():
resp = requests.get(f"{PUBLIC}/rest/workspaces.json", timeout=10)
assert resp.status_code in (401, 403, 404), \
"the catalog REST API answered a public request"
def test_transaction_requires_authentication():
body = ('<wfs:Transaction service="WFS" version="2.0.0" '
'xmlns:wfs="http://www.opengis.net/wfs/2.0"/>')
resp = requests.post(f"{PUBLIC}/wfs", data=body, timeout=10,
headers={"Content-Type": "text/xml"})
assert resp.status_code in (401, 403) or b"Exception" in resp.content
def test_internal_layers_are_absent_from_public_capabilities():
caps = requests.get(f"{PUBLIC}/wms", timeout=30, params={
"SERVICE": "WMS", "REQUEST": "GetCapabilities"}).text
for layer in ("internal:staff_addresses", "internal:draft_zoning"):
assert layer not in caps, f"{layer} is advertised publicly"
def test_oversized_request_is_rejected():
resp = requests.get(f"{PUBLIC}/wms", timeout=60, params={
"SERVICE": "WMS", "VERSION": "1.3.0", "REQUEST": "GetMap",
"LAYERS": "public:basemap", "CRS": "EPSG:3857", "BBOX": "0,0,1,1",
"WIDTH": "10000", "HEIGHT": "10000", "FORMAT": "image/png"})
assert "xml" in resp.headers.get("content-type", ""), \
"a 100-megapixel request was served"
The capabilities test is the one that catches the most real regressions: a layer moved into the wrong workspace, or a rule removed during unrelated work, shows up there immediately and nowhere else.
Limits are a security control and a capacity control at once. The same pixel cap that stops an accidental hundred-megapixel request stops a deliberate one, and it costs a comparison before any allocation. That efficiency is why limits belong at the proxy rather than inside the renderer.
Rate limit per identity, not per address. A shared corporate NAT presents thousands of users as one address, and a per-address bucket either throttles all of them or none. Where clients authenticate, the identity is the correct key; where they do not, an address bucket is a blunt approximation worth setting generously.
Authentication has a cost. Basic authentication against an external identity provider on every tile request adds a round trip to a request that should take milliseconds. Caching the authentication decision for a short window, or issuing a token the proxy can validate locally, keeps a tile workload viable.
Public and authenticated traffic have different shapes. Splitting them onto separate instances lets the public one be sized for cacheable tile traffic and the internal one for heavier analytic queries, rather than sizing one instance for the union — the approach Performance Tuning for OGC Service Backends describes.
Bounded responses limit exfiltration and cost together. A feature count cap with paging means bulk extraction takes many requests, which a rate limit then bounds. Neither control alone achieves that, and together they cost almost nothing.
Over TLS, for service-to-service automation, yes — it is simple, universally supported and adds no round trip. Over plain HTTP it is a reusable password sent in clear text on every request, which no configuration elsewhere compensates for. The client shown above refuses plain HTTP to a non-local host for exactly that reason.
No. It can create data stores, rewrite styles and delete layers, and it shares a port with the map endpoints, so publishing one publishes the other unless you separate them. Bind it to an internal listener or block the path at the proxy for external traffic; guarding it with a password is a weaker control than not exposing it.
Cap the feature count per request and require paging, then rate limit per identity. Neither alone is sufficient — a cap without a rate limit just means more requests, and a rate limit without a cap means each request can still return everything. Together they make extraction slow enough to be visible in monitoring.
At the reverse proxy, before the service allocates anything. By the time GeoServer evaluates a security rule it has already accepted the connection, parsed the request and begun planning work. A limit at the gate costs a counter lookup; the same limit inside the renderer costs everything up to the point it fires.
They can. A capabilities document enumerates layer names, attributes, reference systems and extents, which is a complete inventory of what is published. GeoServer filters it by the caller’s permissions, so the control exists — what is worth verifying is that a layer intended to be internal is genuinely absent from the anonymous document, which is a one-line test.
Back to Python Automation for GeoServer & MapServer
Related