Security Boundaries & Auth: A Workflow for Locking Down Spatial Dashboards
Spatial dashboards routinely expose high-value geospatial assets: infrastructure networks, environmental monitoring grids, cadastral parcel boundaries, and operational telemetry. When these applications are deployed internally or shared across departments, undefined access controls quickly become compliance liabilities. Unlike a conventional analytics dashboard, a spatial tool leaks precise location intelligence the moment an unauthorized user can read a geometry column — making security a first-class architectural requirement, not an afterthought.
This page frames security as an end-to-end workflow you can master and verify: define an authentication boundary, manage session tokens, enforce role-based access, push authorization down to the query engine, and instrument audit logging — each step backed by runnable Python you can drop into a Streamlit or Panel deployment.
This guide provides a production-tested workflow for implementing authentication, enforcing authorization, and securing spatial data pipelines in Streamlit and Panel environments. It assumes you are building internal tooling where data scientists, GIS analysts, and engineering teams share a common deployment surface but require differentiated access to geometry datasets, layer exports, and telemetry endpoints. Because authorization decisions depend on which regions a session is allowed to see, the security model is tightly coupled to your data flow architecture — the path geometry takes from the query engine to the browser is exactly the path you must guard.
Jump to heading Architecture: Where Security Fits in the Spatial Stack
The diagram below shows the three enforcement layers — network proxy, Python process, and query engine — and how an authenticated request flows through each before geometry data reaches the browser.
Jump to heading Prerequisites & Environment Baseline
Before implementing security layers, confirm your environment meets these requirements:
- Python 3.10+ with
streamlit>=1.32orpanel>=1.4 - Identity Provider (IdP): An OIDC/OAuth 2.0 or SAML provider (Keycloak, Azure AD, Okta, Auth0) capable of issuing JWTs or session cookies
- Spatial data stack:
psycopg2orsqlalchemywired to PostGIS, orduckdb>=0.10with the spatial extension loaded - Reverse proxy (Nginx, Traefik, or Authelia) deployed in front of the dashboard process for enterprise contexts
- Familiarity with session state patterns to prevent credential leakage across reruns and widget interactions, and an understanding of how widget lifecycle management interacts with token refresh cycles
Security must be treated as a first-class data dependency within the broader Core Dashboard Architecture & State Management model — not a UI overlay bolted on at the end.
Jump to heading Core Implementation Workflow
Jump to heading Step 1 — Define the Authentication Boundary
Streamlit and Panel execute as long-lived Python processes. Embedding credential validation inside UI callbacks creates maintenance overhead, complicates testing, and increases attack surface. The recommended boundary is one of:
- Reverse proxy authentication: Nginx, Traefik, or Authelia intercepts requests, validates tokens against your IdP, and forwards only authenticated sessions to the dashboard process via standardized headers (
X-Forwarded-User,Authorization: Bearer <token>). - Framework-native auth:
streamlit-authenticatororpanel.authhandle OIDC flows, token refresh, and session cookies internally — suitable for smaller teams without a proxy tier.
Proxy-based approaches are strongly preferred for enterprise deployments because they centralize policy enforcement and allow IdP rotation without touching Python source.
# panel_auth_config.py — configure Panel's built-in OIDC flow
import panel as pn
pn.extension()
# Panel reads these at startup from environment variables:
# PANEL_OAUTH_PROVIDER=azure
# PANEL_OAUTH_KEY=<client-id>
# PANEL_OAUTH_SECRET=<client-secret>
# PANEL_OAUTH_REDIRECT_URI=https://dashboards.example.com/login
# PANEL_OAUTH_SCOPE=openid email groups
def get_current_user() -> dict:
"""Return the authenticated user dict from Panel's OIDC session."""
user = pn.state.user_info
if not user:
raise PermissionError("No authenticated session found.")
return user
Never hardcode client secrets in dashboard scripts; inject them at container startup via a secrets manager.
Jump to heading Step 2 — Manage Session Tokens Securely
Once authentication succeeds, the dashboard must manage the resulting session without leaking tokens across users or reruns. In Streamlit, st.session_state survives script reruns but resets on browser refresh unless backed by server-side storage. Panel’s pn.state offers more granular control and supports explicit cleanup via pn.state.on_session_destroyed.
Token lifecycle requires three coordinated steps:
- Secure storage: Store access tokens and refresh tokens in server-side session dictionaries, never in client-side localStorage or URL parameters.
- Expiration validation: Check
expclaims before executing spatial queries. If a token is within a configurable threshold of expiry, trigger a silent refresh. - State isolation: Scope tokens to individual user sessions. Cross-user state pollution is a common vulnerability in multi-tenant deployments.
# token_lifecycle.py — Streamlit token management
import time
import streamlit as st
import requests
TOKEN_REFRESH_THRESHOLD_SECONDS = 120 # refresh 2 min before expiry
def validate_or_refresh_token() -> str:
"""Return a valid access token, refreshing silently if near expiry."""
access_token: str = st.session_state.get("access_token", "")
expires_at: float = st.session_state.get("token_expires_at", 0.0)
refresh_token: str = st.session_state.get("refresh_token", "")
if not access_token:
st.error("Session not authenticated. Please reload and log in.")
st.stop()
time_remaining = expires_at - time.time()
if time_remaining < TOKEN_REFRESH_THRESHOLD_SECONDS:
resp = requests.post(
"https://auth.example.com/realms/spatial/protocol/openid-connect/token",
data={
"grant_type": "refresh_token",
"client_id": "spatial-dashboard",
"refresh_token": refresh_token,
},
timeout=5,
)
resp.raise_for_status()
payload = resp.json()
st.session_state["access_token"] = payload["access_token"]
st.session_state["token_expires_at"] = time.time() + payload["expires_in"]
access_token = payload["access_token"]
return access_token
Configure deployment cookies with HttpOnly, Secure, and SameSite=Strict flags to mitigate session fixation and replay attacks.
Jump to heading Step 3 — Enforce Role-Based Access Control
Map IdP group claims to dashboard roles (viewer, analyst, admin). Roles dictate which pages, layers, and export functions are rendered. Build a thin RBAC module that the dashboard calls before any spatial query or widget render:
# rbac.py — role extraction and permission guards
from __future__ import annotations
from functools import wraps
from typing import Callable, Set
import streamlit as st
ROLE_PERMISSIONS: dict[str, Set[str]] = {
"viewer": {"view_basemap", "view_public_layers"},
"analyst": {"view_basemap", "view_public_layers",
"view_restricted_layers", "run_spatial_query"},
"admin": {"view_basemap", "view_public_layers",
"view_restricted_layers", "run_spatial_query",
"export_geojson", "manage_users"},
}
def get_user_role() -> str:
"""Extract the dashboard role from the JWT groups claim stored in session."""
groups: list = st.session_state.get("user_groups", [])
if "spatial-admin" in groups:
return "admin"
if "spatial-analyst" in groups:
return "analyst"
return "viewer"
def require_permission(permission: str):
"""Decorator: stop execution and show access-denied if permission absent."""
def decorator(fn: Callable):
@wraps(fn)
def wrapper(*args, **kwargs):
role = get_user_role()
if permission not in ROLE_PERMISSIONS.get(role, set()):
st.warning(
f"Access denied: your role ({role}) cannot perform '{permission}'."
)
st.stop()
return fn(*args, **kwargs)
return wrapper
return decorator
For a full implementation of permission matrices, region assignments, and UI gating patterns, see Implementing role-based access control for internal dashboards.
Jump to heading Step 4 — Push Authorization to the Query Engine
Authentication verifies identity; authorization governs data visibility. In spatial dashboards, the critical control is ensuring that no geometry a user is not authorized to view ever travels across the database connection. Frontend filtering is insufficient — it is trivially bypassed and still transmits raw data to the server process. The correct enforcement point sits inside the data flow architecture itself: the same parameterized predicate that scopes a query to the current map viewport also scopes it to the user’s permitted regions.
Apply authorization predicates at the PostGIS layer using parameterized queries:
# spatial_query.py — row-level geometry filtering in PostGIS
import psycopg2
import geopandas as gpd
from shapely import wkb
def fetch_authorized_features(
conn: psycopg2.extensions.connection,
map_bounds_geom, # shapely Polygon in EPSG:4326
allowed_regions: tuple, # e.g. ("region_north", "region_east")
) -> gpd.GeoDataFrame:
"""
Fetch infrastructure grid features that:
- intersect the current map viewport (ST_Intersects, EPSG:4326)
- belong to a region the authenticated user may view
Both predicates run inside PostGIS — no over-fetch occurs.
"""
bounds_wkb: bytes = wkb.dumps(map_bounds_geom, include_srid=True)
query = """
SELECT geom, asset_id, region_code, asset_class, last_telemetry_ts
FROM infrastructure_grid
WHERE ST_Intersects(geom, ST_GeomFromWKB(%s, 4326))
AND region_code = ANY(%s)
LIMIT 5000
"""
return gpd.read_postgis(
query,
conn,
params=(psycopg2.Binary(bounds_wkb), list(allowed_regions)),
geom_col="geom",
crs="EPSG:4326",
)
The LIMIT 5000 guard prevents memory spikes from large result sets — calibrate this to your geometry complexity and the browser’s GeoJSON payload threshold (typically 5–10 MB for smooth rendering).
Jump to heading Step 5 — Instrument Audit Logging
Every spatial query, layer toggle, and export action should be logged for compliance. Emit structured events that include the user identifier, session ID, action type, query parameters (sanitized), and authorization outcome:
# audit.py — structured spatial event logging
import logging
import json
import time
audit_logger = logging.getLogger("spatial.audit")
def log_spatial_event(
user_id: str,
session_id: str,
action: str,
resource: str,
outcome: str,
extra: dict | None = None,
) -> None:
"""
Emit a JSON audit event for compliance ingestion.
outcome: "allowed" | "denied"
action: "query_executed" | "layer_toggled" | "export_triggered"
"""
event = {
"ts": time.time(),
"user_id": user_id, # pseudonymized if required by policy
"session_id": session_id,
"action": action,
"resource": resource,
"outcome": outcome,
**(extra or {}),
}
audit_logger.info(json.dumps(event))
Forward audit logs to a centralized SIEM or compliance platform. Ensure retention policies align with your organization’s data governance standards and applicable privacy regulations.
Jump to heading Advanced Patterns
Jump to heading Cross-Tab State Isolation
If multiple browser windows share the same session ID (a known risk in Streamlit’s default session model), spatial filters set in one tab can inadvertently influence another. Prevent this by scoping filter state to a window-specific key:
import streamlit as st
import uuid
if "window_id" not in st.session_state:
st.session_state["window_id"] = str(uuid.uuid4())[:8]
window_id = st.session_state["window_id"]
def scoped_key(name: str) -> str:
"""Return a session+window-scoped state key to prevent cross-tab bleed."""
user_id = st.session_state.get("user_id", "anon")
return f"{user_id}:{window_id}:{name}"
# Store spatial filters under scoped keys
st.session_state[scoped_key("active_regions")] = selected_regions
This pattern builds on the tab isolation techniques covered in session state patterns.
Jump to heading Open Policy Agent for Dynamic Authorization Rules
For teams managing many data products with evolving access rules, embedding RBAC logic in Python becomes brittle. Open Policy Agent (OPA) externalizes policy as versioned Rego rules evaluated via a lightweight HTTP sidecar:
# opa_client.py — query OPA for a permission decision
import httpx
OPA_URL = "http://localhost:8181/v1/data/spatial/authz/allow"
def opa_is_allowed(
user_id: str, action: str, resource: str, region: str
) -> bool:
"""
Ask OPA whether user_id may perform action on resource within region.
Returns False on any network error to fail closed.
"""
try:
resp = httpx.post(
OPA_URL,
json={"input": {
"user": user_id,
"action": action,
"resource": resource,
"region": region,
}},
timeout=0.5,
)
return resp.json().get("result", False)
except httpx.RequestError:
return False # fail closed on OPA unavailability
Jump to heading Panel Session Cleanup on Disconnect
In Panel deployments, use pn.state.on_session_destroyed to release database cursors, clear in-memory GeoDataFrames, and revoke temporary file handles when a user disconnects or times out:
# panel_cleanup.py
import panel as pn
import geopandas as gpd
_session_geodataframes: dict[str, gpd.GeoDataFrame] = {}
def register_session_cleanup(session_id: str) -> None:
def cleanup(session_context):
gdf = _session_geodataframes.pop(session_id, None)
if gdf is not None:
del gdf # release geometry memory
cursor = pn.state.cache.pop(f"{session_id}:db_cursor", None)
if cursor:
cursor.close()
pn.state.on_session_destroyed(cleanup)
Jump to heading Verification & Testing
Confirm each security control functions correctly before shipping to production:
# test_security.py — pytest assertions for auth and RBAC
import pytest
import time
from unittest.mock import patch
from rbac import get_user_role, ROLE_PERMISSIONS
from token_lifecycle import validate_or_refresh_token
@pytest.fixture
def analyst_session(monkeypatch):
"""Simulate an authenticated analyst in st.session_state."""
monkeypatch.setattr("streamlit.session_state", {
"user_groups": ["spatial-analyst"],
"user_id": "u-test-001",
"access_token": "eyJ.fake.token",
"token_expires_at": time.time() + 3600,
"refresh_token": "refresh_fake",
})
def test_analyst_role_resolved(analyst_session):
assert get_user_role() == "analyst"
def test_analyst_cannot_export(analyst_session):
assert "export_geojson" not in ROLE_PERMISSIONS["analyst"]
def test_admin_has_export_permission():
assert "export_geojson" in ROLE_PERMISSIONS["admin"]
def test_token_does_not_refresh_when_valid(monkeypatch, analyst_session):
with patch("token_lifecycle.requests.post") as mock_post:
token = validate_or_refresh_token()
mock_post.assert_not_called()
assert token == "eyJ.fake.token"
Run the full suite with pytest -q test_security.py. For Panel, validate that unauthorized routes return HTTP 403 using a headless Playwright session or httpx integration probe against your deployed server.
Jump to heading Troubleshooting
KeyError: 'access_token' on first rerun after login redirect : The OAuth callback sets the token in a cookie or query parameter that Streamlit does not automatically map into st.session_state. Add an initialization guard at the top of app.py that reads the token from st.query_params and writes it into session state before any widget renders.
JWT signature verification fails intermittently : Clock skew between your dashboard host and the IdP is the most common cause. Add a 30-second leeway when validating iat and nbf claims: jwt.decode(token, key, algorithms=["RS256"], leeway=30).
Users see geometry from other sessions in Panel : A module-level GeoDataFrame cache shared across sessions is the typical culprit. Move per-user data into pn.state.cache keyed by session ID, or into the cleanup pattern shown above.
psycopg2.ProgrammingError: can't adapt type 'bytes' : Wrap the WKB byte string with psycopg2.Binary() before passing it as a query parameter. Plain Python bytes objects are not automatically adapted to the PostgreSQL bytea wire format.
CSP header blocks Leaflet or Deck.gl tile requests : Add the tile server origin to the img-src and connect-src directives of your Content-Security-Policy header. For WebGL layers, include 'unsafe-eval' in script-src only if your Deck.gl build requires it — prefer a CSP-compliant build otherwise.
Jump to heading Performance Considerations
| Control | Spatial workload impact | Recommendation |
|---|---|---|
| Per-query token validation | Adds ~1–5 ms per query | Cache the decoded token dict in session state for the token’s lifetime; re-decode only on refresh |
| OPA sidecar latency | 5–50 ms per decision | Batch permission checks for all layers before a multi-layer render; never call OPA per geometry row |
| PostGIS row-level filter | ST_Intersects on unindexed columns: 2–30 s | Ensure a GiST spatial index exists on every geometry column used in auth predicates |
| Audit log serialization | Structured JSON emit: ~0.1 ms | Use Python’s logging with a JSON formatter and async handlers to avoid I/O blocking |
| Session cleanup on destroy | GDF gc() is synchronous | del the GeoDataFrame reference; for large arrays call gc.collect() explicitly in cleanup handlers |
For query result caching that complements these auth patterns, see Caching Strategies & Async Performance Tuning.
Jump to heading Deployment Hardening Checklist
- [ ] Authentication boundary enforced at the proxy or framework level — not inside UI callbacks
- Tokens stored server-side; cookies flagged
HttpOnly,Secure,SameSite=Strict - [ ] Authorization predicates pushed to PostGIS/DuckDB; no client-side spatial filtering
- [ ] Session state isolated per user; scoped window keys prevent cross-tab bleed
- [ ] Secrets externalized to a vault (HashiCorp Vault, AWS Secrets Manager, Kubernetes Secrets)
- [ ] TLS 1.2+ enforced on all endpoints including WebSocket connections for live telemetry
- CSP headers restrict script execution and
img-srcto known tile origins - [ ] Audit logs emit structured events (user ID, session ID, action, outcome) to a SIEM
- [ ] GiST indexes present on geometry columns used in authorization predicates
-
pn.state.on_session_destroyedcleanup registered for all Panel deployments
Should authentication live in the Streamlit script or at the proxy?
The proxy level is strongly preferred for enterprise deployments. Centralizing auth at Nginx, Traefik, or Authelia decouples identity logic from dashboard code, allows IdP rotation without touching Python, and prevents unauthenticated requests from ever reaching the Streamlit process. Use streamlit-authenticator only for small internal tools where deploying a proxy tier is impractical.
How do I prevent one user's spatial filters from leaking into another session?
Store all filter state under session-scoped keys — prefix every key with the session ID or user ID. Never write to module-level globals or shared class attributes. In Panel, register pn.state.on_session_destroyed to wipe GeoDataFrame caches when the session closes. The scoped key pattern in the cross-tab section above applies equally to single-window isolation.
What is the safest way to pass allowed geometry regions to a PostGIS query?
Always use parameterized queries via psycopg2 or SQLAlchemy — never string-format geometry values into SQL. Pass the WKB byte string wrapped in psycopg2.Binary() and the allowed region codes as a list bound to ANY(%s). The database driver handles escaping; your code never constructs SQL strings from user-controlled values.
Back to Core Dashboard Architecture & State Management
Related