Sharing a Map App: Public, Private and In Between
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.
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"
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.
Step-by-step solution
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 have no real authentication model, and rolling one inside the app leaves the underlying port reachable. 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.
3. Read the identity, and use it
import streamlit as st
def current_user() -> dict | None:
"""An authenticating proxy passes the identity in headers."""
headers = st.context.headers if hasattr(st, "context") else {}
email = headers.get("X-Forwarded-Email") or headers.get("X-Auth-Request-Email")
if not email:
return None
return {"email": email,
"groups": (headers.get("X-Forwarded-Groups") or "").split(",")}
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 โ which is why network isolation and header trust go together.
4. Filter the data by identity where the data requires it
def visible_to(gdf, user):
if user is None:
return gdf.head(0)
if "admin" in user["groups"]:
return gdf
regions = REGIONS_BY_GROUP.get(tuple(sorted(user["groups"])), [])
return gdf[gdf["region"].isin(regions)]
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
import logging
logging.info("view user=%s region=%s features=%d",
user["email"] if user else "anonymous", region, 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?"
Code examples
Example 1 โ identity, authorisation and a fail-closed default
import streamlit as st
REGIONS_BY_GROUP = {
"gis-admins": None, # None means everything
"north-team": ["North", "North West"],
"south-team": ["South", "South East"],
}
def current_user():
headers = getattr(st.context, "headers", {}) or {}
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}
def permitted_regions(user) -> list | None:
if user is None:
return [] # fail closed
for group in user["groups"]:
if group in REGIONS_BY_GROUP:
allowed = REGIONS_BY_GROUP[group]
if allowed is None:
return None # everything
return allowed
return []
def visible(gdf, user):
allowed = permitted_regions(user)
if allowed is None:
return gdf
return gdf[gdf["region"].isin(allowed)]
user = current_user()
if user is None:
st.error("You are not signed in. This app is only available through "
"the organisation's single sign-on.")
st.stop()
districts = visible(load_districts(), user)
st.caption(f"Signed in as {user['email']} ยท "
f"{len(districts):,} districts visible to you")
st.stop() after the check is what makes it fail closed: nothing below runs, so no data is rendered to an unidentified caller.
Example 2 โ oauth2-proxy in front of the app
services:
app:
build: .
expose: ["8501"] # not `ports` โ never reachable directly
environment:
TRUST_PROXY_HEADERS: "true"
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_SET_XAUTHREQUEST: "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
import pytest
@pytest.mark.parametrize("groups,expected_regions", [
(["north-team"], {"North", "North West"}),
(["south-team"], {"South", "South East"}),
(["gis-admins"], None),
([], set()),
(["unknown-group"], set()),
])
def test_visibility(districts, groups, expected_regions):
user = {"email": "[email protected]", "groups": groups}
subset = visible(districts, user)
if expected_regions is None:
assert len(subset) == len(districts)
else:
assert set(subset["region"]) <= expected_regions
def test_anonymous_sees_nothing(districts):
assert len(visible(districts, None)) == 0
def test_export_respects_the_same_filter(districts):
"""The commonest leak: a filtered map and an unfiltered download."""
user = {"email": "[email protected]", "groups": ["north-team"]}
subset = visible(districts, user)
payload = to_csv(subset, key="test").decode()
assert "South" not in payload
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.
Explanation
Why authentication in front of the app is the right architecture
Streamlit and Panel are not designed as authentication boundaries: they have no user model, no session hardening and no route-level access control.
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.
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 referrer headers, 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.
Edge cases or notes
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 history and referrers, 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.