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.

The sharing options for a map app, from an unguessable link to per-user data. four rungs, not two optionsan unguessable linkno identitya bearer credential in a URLa shared passwordone credentialno audit trail, and it leakssingle sign-onreal identitythe app knows who is askingper-user data filteringauthorisationidentity decides what is returnedAuthentication is who you are; authorisation is what you may see spatialworkflow.io
"We put a login on it" answers the first and nothing about the second.

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?"

Authentication in front of the app, with the app trusting one header. the app never handles a credentialbrowsersigns in at the IdPauth proxyoauth2-proxy orequivalentidentity headerX-Forwarded-Emailapp filters byidentityand fails closedTrust the header only when the app port is not published β€” expose, not ports spatialworkflow.io
Streamlit and Panel have no user model; a proxy is the right boundary.

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.

What a Streamlit app filtered by identity showed when opened four ways in a real browser. one app, four ways inidentity the app sawdistricts shownstraight to the portnoneβœ“0 β€” fails closedSSO proxy, as alicealice, north-teamβœ“62 of 296alice forges adminalice, north-teamβœ“62 β€” proxy stripped itclient sets headersmallory, gis-adminsβœ•all 296The last row is why the app’s own port must never be reachable spatialworkflow.io
Example 2’s four routes, as the browser saw them.

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.

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.

A correctly restricted map beside an unrestricted download. the commonest leak in a spatial appfiltered lateβœ•the map shows one regionβœ•the download serialises the full frameβœ•one click bypasses the restrictionβœ•nothing errorsfiltered once, at loadβœ“no unfiltered frame is in scopeβœ“map, chart, metrics and export agreeβœ“export permission can differ from viewβœ“a test asserts itRestricting the map is visible and gets done; the export serialises a variable spatialworkflow.io
Filter immediately after loading, so there is nothing unfiltered to serialise.

Exposed ports, trusted headers and export rights

  • expose, not ports, 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.

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.