Sharing a Map App: Public, Private and In Between
On this page
Problem statement
The app works. Now somebody has to be able to open it, and that decision is usually made once, hastily, and lived with for years.
The options are not "public or behind a login". They form a ladder, and each rung has different consequences for what the app may show:
- A link nobody can guess β no identity, and anybody with the link has everything.
- A shared password β one credential, no audit trail, and it leaks.
- Single sign-on β real identity, and the app knows who is asking.
- Per-user data filtering β identity used for authorisation, not only authentication.
For a spatial app there is a specific complication: the data frequently cannot be public even when the analysis can. A licensed boundary layer, an address-level dataset, a commercially sensitive site list β the map is publishable and the underlying features are not.
The examples below build and test a real one: a Streamlit app over England's 296 local authority districts, where a regional team sees its own regions, run behind a stand-in for an authenticating proxy and opened in a real browser.
Quick answer
Decide two things separately, because they are separate:
def sharing_plan(*, data_is_public, audience, need_audit, per_user_data):
if per_user_data:
return "SSO + row-level filtering β identity decides what is returned"
if not data_is_public:
return "SSO or an authenticating proxy β no unguessable links"
if need_audit:
return "SSO, so actions are attributable"
if audience == "a handful of colleagues":
return "an authenticating proxy, or a private network"
return "public, with limits: page caps, rate limits, no bulk export"
print(sharing_plan(data_is_public=False, audience="regional teams", need_audit=True, per_user_data=True))
print(sharing_plan(data_is_public=True, audience="anyone", need_audit=False, per_user_data=False))
SSO + row-level filtering β identity decides what is returned
public, with limits: page caps, rate limits, no bulk export
Authentication is who you are. Authorisation is what you may see. A password on the front door answers the first and nothing about the second, which is why "we put a login on it" is not a data-protection measure.
Classify the data, then authenticate in front of the app
1. Classify the data before choosing a mechanism
Three questions settle most of it:
- May the underlying features leave the organisation? Licensed boundaries, address points and commercially sensitive locations frequently may not.
- Is any of it personal data? An address-level layer usually is, and that brings legal obligations rather than preferences.
- Does aggregation make it publishable? Counts by district often are, when the individual points are not.
An app showing aggregates from private data is publishable; the same app with a download button is not.
2. Put authentication in front of the app, not inside it
Streamlit and Panel do not come with a user directory or per-route access control, and a login written inside the app leaves the port it listens on reachable by anything that can connect. Put an authenticating proxy in front:
- oauth2-proxy in front of the container, backed by your identity provider
- Cloudflare Access or an equivalent zero-trust proxy
- The platform's own authentication, where the hosting offers it
- A private network or VPN, which is the simplest correct answer for an internal tool
The app then receives a header identifying the user and never handles a credential. (Streamlit 1.42 and later also offer st.login() with an OpenID Connect provider; it authenticates, but authorisation β what each user may see β is still yours to write.)
3. Read the identity, and use it
def current_user(headers) -> dict | None:
"""An authenticating proxy passes the identity in headers."""
email = headers.get("X-Forwarded-Email") or headers.get("X-Auth-Request-Email")
if not email:
return None
groups = [g for g in (headers.get("X-Forwarded-Groups") or "").split(",") if g]
return {"email": email, "groups": groups}
print(current_user({"X-Forwarded-Email": "alice@example.org", "X-Forwarded-Groups": "north-team"}))
print(current_user({}))
{'email': 'alice@example.org', 'groups': ['north-team']}
None
In the app, headers is st.context.headers. Trust those headers only when the app is reachable exclusively through the proxy. If the container's port is exposed, a client can set them itself β Example 2 does exactly that β which is why network isolation and header trust go together.
4. Filter the data by identity where the data requires it
REGIONS_BY_GROUP = {
"gis-admins": None, # None means everything
"north-team": ["North East", "North West", "Yorkshire and The Humber"],
"south-team": ["South East", "South West"],
}
def permitted_regions(user):
if user is None:
return [] # fail closed
for group in user["groups"]:
if group in REGIONS_BY_GROUP:
return REGIONS_BY_GROUP[group]
return []
def visible(gdf, user):
allowed = permitted_regions(user)
return gdf if allowed is None else gdf[gdf["region"].isin(allowed)]
for groups in (["north-team"], ["south-team"], ["gis-admins"], ["unknown-group"]):
user = {"email": "a@example.org", "groups": groups}
subset = visible(districts, user)
print(f"{groups[0]:14} {len(subset):>3} districts, regions: {sorted(subset['region'].unique())[:3]}")
print(f"{'anonymous':14} {len(visible(districts, None)):>3} districts")
north-team 62 districts, regions: ['North East', 'North West', 'Yorkshire and The Humber']
south-team 91 districts, regions: ['South East', 'South West']
gis-admins 296 districts, regions: ['East Midlands', 'East of England', 'London']
unknown-group 0 districts, regions: []
anonymous 0 districts
Filter before anything renders, and filter the same data the download button will serialise. The commonest leak in a spatial app is a map correctly restricted to one region and an export that was not.
5. Decide what may be exported, separately from what may be viewed
Viewing an aggregate and downloading the features behind it are different permissions. It is entirely reasonable to allow the first and refuse the second:
if user and "download" in user["groups"]:
st.download_button("Download features", export_bytes(subset), ...)
else:
st.caption("Contact the data owner for the underlying features.")
This is the control that lets a licensed dataset drive a widely shared app.
6. Log who saw what, if it matters
logging.info("view user=%s regions=%s features=%d",
user["email"] if user else "anonymous", allowed, len(subset))
An audit trail is a requirement in some settings and a diagnostic aid in all of them. It also answers the question that follows any incident, which is "who had access to this?"
One app reached four ways, failing closed
The data used on this page
England's local authority districts (December 2024, ultra generalised) and the ONS lookup from each district to its region, both from the ONS Open Geography Portal under the Open Government Licence. The setup also defines open_app(), which starts a Streamlit app, optionally behind a small reverse proxy that adds identity headers the way oauth2-proxy does, and reads what a real Chromium browser was shown.
import asyncio
import io
import socket
import subprocess
import sys
import threading
import time
from pathlib import Path
import aiohttp
import geopandas as gpd
import pandas as pd
import requests
from aiohttp import web
from playwright.sync_api import sync_playwright
ONS = "https://services1.arcgis.com/ESMARspQHYMw9BZ9/arcgis/rest/services/"
Path("data").mkdir(exist_ok=True)
if not Path("data/districts.gpkg").exists():
lad = gpd.read_file(io.BytesIO(requests.get(ONS + "Local_Authority_Districts_December_2024_Boundaries_UK_BUC/FeatureServer/0/query",
params={"where": "LAD24CD LIKE 'E%'", "outFields": "LAD24CD,LAD24NM", "outSR": 27700, "f": "geojson"}, timeout=120).content))
lookup = pd.DataFrame([f["attributes"] for f in requests.get(ONS + "LAD24_RGN24_EN_LU/FeatureServer/0/query",
params={"where": "1=1", "outFields": "LAD24CD,RGN24NM", "f": "json"}, timeout=120).json()["features"]])
lad.merge(lookup, on="LAD24CD").rename(columns={"RGN24NM": "region"}).to_file("data/districts.gpkg")
districts = gpd.read_file("data/districts.gpkg")
def free_port():
with socket.socket() as s:
s.bind(("127.0.0.1", 0))
return s.getsockname()[1]
def header_proxy(app_port, inject, strip_client_identity):
"""A reverse proxy for HTTP and websockets that sets identity headers, like oauth2-proxy."""
async def handler(request):
headers = {k: v for k, v in request.headers.items() if k.lower() != "host"}
if strip_client_identity:
headers = {k: v for k, v in headers.items() if not k.lower().startswith("x-forwarded-")}
headers.update(inject)
url = f"http://127.0.0.1:{app_port}{request.path_qs}"
if request.headers.get("Upgrade", "").lower() == "websocket":
client = web.WebSocketResponse(protocols=[p.strip() for p in request.headers.get("Sec-WebSocket-Protocol", "").split(",") if p.strip()])
await client.prepare(request)
skip = ("upgrade", "connection", "sec-websocket-key", "sec-websocket-version", "sec-websocket-extensions", "sec-websocket-protocol")
async with aiohttp.ClientSession() as session:
async with session.ws_connect(url.replace("http", "ws", 1), headers={k: v for k, v in headers.items() if k.lower() not in skip},
protocols=[client.ws_protocol] if client.ws_protocol else ()) as upstream:
async def pump(source, sink):
async for message in source:
if message.type == aiohttp.WSMsgType.BINARY:
await sink.send_bytes(message.data)
elif message.type == aiohttp.WSMsgType.TEXT:
await sink.send_str(message.data)
await asyncio.gather(pump(client, upstream), pump(upstream, client), return_exceptions=True)
return client
async with aiohttp.ClientSession(auto_decompress=False) as session:
async with session.request(request.method, url, headers=headers, data=await request.read()) as reply:
keep = {k: v for k, v in reply.headers.items() if k.lower() not in ("transfer-encoding", "content-length", "connection")}
return web.Response(status=reply.status, body=await reply.read(), headers=keep)
port = free_port()
def serve():
loop = asyncio.new_event_loop()
app = web.Application()
app.router.add_route("*", "/{tail:.*}", handler)
runner = web.AppRunner(app)
loop.run_until_complete(runner.setup())
loop.run_until_complete(web.TCPSite(runner, "127.0.0.1", port).start())
loop.run_forever()
threading.Thread(target=serve, daemon=True).start()
return port
def open_app(script, routes):
"""Start the app once, open it through each route in Chromium, return the text each browser saw."""
app_port = free_port()
server = subprocess.Popen([sys.executable, "-m", "streamlit", "run", script, "--server.headless", "true",
"--server.address", "127.0.0.1", "--server.port", str(app_port),
"--browser.gatherUsageStats", "false"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
seen = {}
try:
ports = {name: app_port if proxy is None else header_proxy(app_port, *proxy) for name, (proxy, _) in routes.items()}
time.sleep(6)
with sync_playwright() as p:
browser = p.chromium.launch()
for name, (_, client_headers) in routes.items():
context = browser.new_context(extra_http_headers=client_headers)
page = context.new_page()
page.goto(f"http://127.0.0.1:{ports[name]}")
page.wait_for_selector("[data-testid='stCaptionContainer'], [data-testid='stAlert']", timeout=60000)
seen[name] = page.inner_text("[data-testid='stMain']").strip().splitlines()[0]
context.close()
browser.close()
finally:
server.terminate()
server.wait()
return seen
print(len(districts), "districts in", districts["region"].nunique(), "regions")
296 districts in 9 regions
Example 1 β the app: identity, authorisation and a fail-closed default
Path("app.py").write_text('''import geopandas as gpd
import streamlit as st
REGIONS_BY_GROUP = {
"gis-admins": None,
"north-team": ["North East", "North West", "Yorkshire and The Humber"],
"south-team": ["South East", "South West"],
}
def current_user(headers):
email = headers.get("X-Forwarded-Email") or headers.get("X-Auth-Request-Email")
if not email:
return None
return {"email": email, "groups": [g for g in (headers.get("X-Forwarded-Groups") or "").split(",") if g]}
def permitted_regions(user):
for group in user["groups"]:
if group in REGIONS_BY_GROUP:
return REGIONS_BY_GROUP[group]
return []
user = current_user(st.context.headers)
if user is None:
st.error("You are not signed in. This app is only available through single sign-on.")
st.stop()
districts = gpd.read_file("data/districts.gpkg")
allowed = permitted_regions(user)
districts = districts if allowed is None else districts[districts["region"].isin(allowed)] # filter once, at load
st.caption(f"Signed in as {user['email']} Β· {len(districts):,} districts visible to you")
st.download_button("Download CSV", districts.drop(columns="geometry").to_csv(index=False), "districts.csv")
''')
print(len(Path("app.py").read_text().splitlines()), "lines written to app.py")
31 lines written to app.py
st.stop() after the check is what makes it fail closed: nothing below runs, so no data is rendered to an unidentified caller. And the filter is applied once, straight after loading, so the download button has no unfiltered frame to serialise.
Example 2 β the same app, reached four ways
routes = {
"straight to the app's port": (None, {}),
"through the SSO proxy as alice": (({"X-Forwarded-Email": "alice@example.org", "X-Forwarded-Groups": "north-team"}, True), {}),
"alice sends a forged admin header": (({"X-Forwarded-Email": "alice@example.org", "X-Forwarded-Groups": "north-team"}, True),
{"X-Forwarded-Groups": "gis-admins"}),
"a client sets its own headers": (({"X-Forwarded-Email": "mallory@example.org", "X-Forwarded-Groups": "gis-admins"}, False), {}),
}
for route, text in open_app("app.py", routes).items():
print(f"{route:36} -> {text}")
straight to the app's port -> You are not signed in. This app is only available through single sign-on.
through the SSO proxy as alice -> Signed in as alice@example.org Β· 62 districts visible to you
alice sends a forged admin header -> Signed in as alice@example.org Β· 62 districts visible to you
a client sets its own headers -> Signed in as mallory@example.org Β· 296 districts visible to you
The first three are the design working: no identity, no data; alice sees the 62 districts of the three northern regions; a header she forges is stripped by the proxy, which sets its own. The fourth is the failure the design depends on avoiding. Anything that can reach the app's port directly can send any header it likes β the "client" here is a two-line header injector, and curl -H would do the same β and the app believed mallory was an administrator. That is why the container publishes no port of its own:
services:
app:
build: .
expose: ["8501"] # not `ports` β never reachable directly
auth:
image: quay.io/oauth2-proxy/oauth2-proxy:latest
ports: ["443:4180"]
environment:
OAUTH2_PROXY_PROVIDER: oidc
OAUTH2_PROXY_OIDC_ISSUER_URL: https://idp.example.org
OAUTH2_PROXY_CLIENT_ID: ${CLIENT_ID}
OAUTH2_PROXY_CLIENT_SECRET: ${CLIENT_SECRET}
OAUTH2_PROXY_COOKIE_SECRET: ${COOKIE_SECRET}
OAUTH2_PROXY_UPSTREAMS: http://app:8501
OAUTH2_PROXY_EMAIL_DOMAINS: example.org
OAUTH2_PROXY_PASS_USER_HEADERS: "true"
OAUTH2_PROXY_HTTP_ADDRESS: 0.0.0.0:4180
depends_on: [app]
expose rather than ports is the important line. If the app's port is published, the identity headers can be forged by anybody who can reach it, and the whole scheme is decorative.
Example 3 β testing that the restriction actually restricts
Path("tests").mkdir(exist_ok=True)
Path("tests/test_visibility.py").write_text('''import geopandas as gpd
import pytest
import importlib.util, sys
spec = importlib.util.spec_from_file_location("rules", "rules.py")
rules = importlib.util.module_from_spec(spec); spec.loader.exec_module(rules)
@pytest.fixture(scope="module")
def districts():
return gpd.read_file("data/districts.gpkg")
@pytest.mark.parametrize("groups,expected", [
(["north-team"], {"North East", "North West", "Yorkshire and The Humber"}),
(["south-team"], {"South East", "South West"}),
(["gis-admins"], None),
([], set()),
(["unknown-group"], set()),
])
def test_visibility(districts, groups, expected):
subset = rules.visible(districts, {"email": "a@example.org", "groups": groups})
if expected is None:
assert len(subset) == len(districts)
else:
assert set(subset["region"]) == expected
def test_anonymous_sees_nothing(districts):
assert len(rules.visible(districts, None)) == 0
def test_export_respects_the_same_filter(districts):
subset = rules.visible(districts, {"email": "a@example.org", "groups": ["north-team"]})
payload = subset.drop(columns="geometry").to_csv(index=False)
assert "South East" not in payload and "London" not in payload
''')
Path("rules.py").write_text('''REGIONS_BY_GROUP = {"gis-admins": None,
"north-team": ["North East", "North West", "Yorkshire and The Humber"],
"south-team": ["South East", "South West"]}
def visible(gdf, user):
if user is None:
return gdf.head(0)
for group in user["groups"]:
if group in REGIONS_BY_GROUP:
allowed = REGIONS_BY_GROUP[group]
return gdf if allowed is None else gdf[gdf["region"].isin(allowed)]
return gdf.head(0)
''')
result = subprocess.run([sys.executable, "-m", "pytest", "-q", "-p", "no:cacheprovider", "tests"], capture_output=True, text=True)
print(result.stdout.strip().splitlines()[-1].split(" in ")[0])
7 passed
The last test is the one worth writing first. A map correctly restricted to one region beside a download that serialises the whole layer is a real and common defect.
Authentication, authorisation and the export leak
Why authentication in front of the app is the right architecture
Streamlit and Panel are not designed as authentication boundaries: they have no user directory and no route-level access control, and the port they listen on answers anyone who connects.
A proxy that terminates the identity, enforces the login and passes a verified header lets the app be a simple program that trusts one thing. It also means the login works identically for every internal app, and the app never handles a credential.
The precondition is network isolation. If the app's port is reachable, the headers are attacker-controlled β Example 2 made an administrator out of a header.
Why authentication and authorisation must be separated
"We put a login on it" answers who and says nothing about what. An app behind SSO that shows every user the whole dataset has authenticated access to data some of those users should not see.
For a spatial app the distinction is sharp, because the data is frequently the sensitive part: the aggregate is publishable and the address points are not. Authorisation is where that difference is enforced, and it lives in the code that selects rows.
Why the export is the leak
Restricting the map is visible and gets done. The download button serialises a variable, and if that variable is the unfiltered frame the restriction is bypassed by one click.
The defence is structural: filter once, immediately after loading, and let everything downstream β map, chart, metrics, export β use the filtered object. Then there is no unfiltered variable in scope to serialise.
Why an unguessable link is not access control
It is a bearer credential in a URL: it appears in browser history, in chat logs and in screenshots, and it cannot be revoked per person or audited.
It is fine for a public artefact you would be comfortable indexing. For anything else it is a decision to have no access control while feeling as though you do.
Exposed ports, trusted headers and export rights
expose, notports, for the app container β or the identity headers are forgeable.- Trust identity headers only through the proxy; treat them as untrusted otherwise.
- Fail closed. An unrecognised user sees nothing, not everything.
- Filter once, at load, so no unfiltered frame is in scope.
- Export permission is separate from view permission.
- Streamlit sessions are per tab, not per user β do not use them as an identity.
- Log the identity with the query, for audit and for diagnosis.
- Aggregation may make private data publishable β check the disclosure rules that apply.
Internal links
- How to deploy a Python map app with Docker β where the proxy sits
- Authentication and rate limits for a spatial API β the same decisions for an API
- How to add a download button for filtered spatial data β the export that must respect the filter
- How to manage pipeline secrets and credentials β keeping client secrets out of the source
- Notebook, app or report: choosing how to ship an analysis β the wider delivery decision
- How to test a map app without a browser β testing the restriction
- Spatial dashboards explained: when an app beats a map image β whether to build one
- How to package GIS deliverables β sharing data instead of an app
FAQ
How do I put a login on a Streamlit app?
In front of it, not inside it: oauth2-proxy, a zero-trust proxy, or the platform's own authentication. The app then reads a verified identity header and never handles a credential.
Is an unguessable URL good enough?
Only for something you would be comfortable publishing. It is a bearer credential that appears in browser history and chat logs, cannot be revoked per person, and cannot be audited.
What is the difference between authentication and authorisation?
Authentication is who you are; authorisation is what you may see. A login answers the first and says nothing about the second.
How do I show different users different data?
Filter immediately after loading, using the identity from the proxy, and let every downstream consumer use the filtered object. Fail closed for unrecognised users.
What is the commonest security mistake in a map app?
A correctly restricted map beside a download button that serialises the unfiltered layer. Filter once, at load, so no unfiltered frame is in scope.
Can I trust the headers the proxy sets?
Only if the app is reachable exclusively through the proxy. Use expose rather than ports, or a client can set those headers itself.