Gate access server-side by mapping identity provider claims to a permission frozenset, then filter spatial payloads at the query layer before any data reaches the browser — never rely on frontend conditional rendering to protect sensitive geospatial data.

Jump to heading Why this matters

Spatial dashboards routinely carry high-value datasets: infrastructure networks, sensitive parcel boundaries, operational telemetry, and environmental monitoring grids. When those dashboards are shared across departments or opened to a wider internal audience, undefined access controls become a compliance liability almost immediately. Access control in this context is not a UI challenge — it is a data-filtering problem that belongs to your data flow architecture: the role boundary has to live on the path between the database and the serialiser, not in the render layer. Without server-side enforcement, a viewer role can inspect raw GeoJSON responses in browser developer tools regardless of what the React or Jinja template chooses to render.

The patterns on this page sit inside the broader Security Boundaries & Auth workflow, which covers authentication boundaries, token lifecycle, and deployment hardening. This page focuses narrowly on the permission-matrix and payload-filtering mechanics you implement once identity has been established — and on keeping role data correctly scoped inside session state so that the framework’s per-interaction reruns never leak one user’s permissions to another.


Jump to heading The three-stage pipeline

Three-stage RBAC pipeline for spatial dashboardsA left-to-right flow of three stages — Identity Verification at the IdP or reverse proxy, Claim Extraction parsing a JWT or X-Forwarded-User header, and Role-to-Permission Mapping via a hashable frozenset lookup — whose output drives a server-side spatial filter (a PostGIS or DuckDB WHERE clause) that runs before any geometry is serialised to the browser.STAGE 1Identity VerificationIdP / reverse proxySTAGE 2Claim ExtractionJWT / X-Forwarded-UserSTAGE 3Role → Permissionhashable frozenset lookuppermission setENFORCEMENT BOUNDARYServer-side spatial filterPostGIS / DuckDB WHERE clause

Jump to heading Prerequisites

  • Python 3.11+, streamlit>=1.35 or panel>=1.4
  • geopandas>=0.14, psycopg2-binary>=2.9 (for PostGIS queries), python-jose>=3.3 (for JWT validation)
  • An OIDC-capable identity provider (Keycloak, Okta, Azure AD, or Auth0) configured to issue JWTs with group or role claims
  • A working understanding of session state patterns — in particular how Streamlit re-executes the full script on every widget interaction and why permission lookups must be deterministic

Jump to heading Step-by-step solution

Jump to heading Step 1 — Verify identity at the infrastructure boundary

Never implement credential storage or password hashing inside the dashboard script. Route authentication through a reverse proxy (Nginx with auth_request, Traefik with ForwardAuth middleware, or the standalone oauth2-proxy) or use a framework-native library (streamlit-authenticator for OIDC, panel.auth for OAuth 2.0 flows).

The proxy intercepts every request, validates the token against the IdP’s JWKS endpoint, then forwards the authenticated session with a trusted header:

code
X-Forwarded-User: [email protected]
X-Forwarded-Groups: gis-analysts,data-team
Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...

Your dashboard script must consume these headers, not generate them.

Jump to heading Step 2 — Extract and cache role claims at startup

Parse the forwarded headers or decode the JWT on the very first execution and store the resolved role in session state. Subsequent reruns skip parsing overhead by reading directly from session state.

python
import streamlit as st
from jose import jwt, JWTError
import os

JWKS_URI = os.environ["IDP_JWKS_URI"]           # e.g. https://auth.example.com/realms/main/protocol/openid-connect/certs
AUDIENCE  = os.environ["IDP_CLIENT_ID"]

def _extract_role_from_token(raw_token: str) -> str:
    """Decode and validate a JWT, return the role claim or default to 'viewer'."""
    try:
        # python-jose validates signature, expiry, and audience automatically
        claims = jwt.decode(raw_token, JWKS_URI, algorithms=["RS256"], audience=AUDIENCE)
        # IdP role claim is typically nested; adapt the key path to your IdP
        roles = claims.get("realm_access", {}).get("roles", [])
        for role in ("admin", "analyst", "viewer"):
            if role in roles:
                return role
    except JWTError:
        pass
    return "viewer"

# Only parse once per session
if "user_role" not in st.session_state:
    raw_token = st.context.headers.get("Authorization", "").removeprefix("Bearer ")
    st.session_state["user_role"] = _extract_role_from_token(raw_token)

Jump to heading Step 3 — Build the role-to-permission matrix

Map each role to a frozenset of permission strings. frozenset is hashable, which matters when you use it as a cache key later.

python
from typing import FrozenSet, Dict

ROLE_PERMISSIONS: Dict[str, FrozenSet[str]] = {
    "viewer":  frozenset({"read_maps", "view_public_layers"}),
    "analyst": frozenset({"read_maps", "view_public_layers",
                          "export_data", "run_spatial_analysis"}),
    "admin":   frozenset({"read_maps", "view_public_layers",
                          "export_data", "run_spatial_analysis",
                          "manage_users", "edit_sensitive_layers"}),
}

def current_permissions() -> FrozenSet[str]:
    role = st.session_state.get("user_role", "viewer")
    return ROLE_PERMISSIONS.get(role, frozenset())

def require(permission: str) -> bool:
    return permission in current_permissions()

Jump to heading Step 4 — Filter spatial payloads at the query layer

This is the critical enforcement point. Never fetch a full PostGIS table and then filter in Python — the restricted rows have already left the database by that point. Apply the permission predicate inside the SQL WHERE clause.

Role-scoped region filtering at the query layerThree roles on the left — viewer, analyst, admin — each map to a set of allowed region codes: viewer to PUBLIC only, analyst to PUBLIC and RESTRICTED, admin to PUBLIC, RESTRICTED, and CLASSIFIED. A central WHERE clause filters the infrastructure grid by region_code, so the viewer receives 142 features, the analyst 389, and the admin 521 — the restricted geometry never leaves the database for lower roles.ROLEALLOWED region_codeRESULTviewerread_mapsPUBLIC142featuresanalyst+ run_spatial_analysisPUBLICRESTRICTED389featuresadmin+ edit_sensitive_layersPUBLICRESTRICTEDCLASSIFIED521featuresWHEREregion_code= ANY(scope)Restricted geometry never leaves the database for lower roles —the filter runs in the query engine, not in Python or the browser.

The example below queries an infrastructure grid for EPSG:4326 features intersecting the user’s viewport bounding box, restricted to region codes the role is permitted to access:

python
import geopandas as gpd
import psycopg2
from shapely.geometry import box

# Allowed region codes per role — extend this to a proper lookup table in production
ROLE_REGIONS: Dict[str, tuple] = {
    "viewer":  ("PUBLIC",),
    "analyst": ("PUBLIC", "RESTRICTED"),
    "admin":   ("PUBLIC", "RESTRICTED", "CLASSIFIED"),
}

@st.cache_data(ttl=300, hash_funcs={frozenset: lambda s: hash(s)})
def load_infrastructure(
    user_role: str,
    bbox: tuple[float, float, float, float],  # (min_lon, min_lat, max_lon, max_lat)
) -> gpd.GeoDataFrame:
    """Return only features the user's role is permitted to see, clipped to bbox."""
    allowed_regions = ROLE_REGIONS.get(user_role, ("PUBLIC",))
    # bbox: Vancouver area example — (lon_min, lat_min, lon_max, lat_max)
    # e.g. (-123.25, 49.18, -123.02, 49.32)
    minx, miny, maxx, maxy = bbox
    query = """
        SELECT geom, asset_id, asset_class, region_code
        FROM infrastructure_grid
        WHERE ST_Intersects(
                geom,
                ST_MakeEnvelope(%(minx)s, %(miny)s, %(maxx)s, %(maxy)s, 4326)
              )
          AND region_code = ANY(%(regions)s)
    """
    with psycopg2.connect(os.environ["POSTGIS_DSN"]) as conn:
        return gpd.read_postgis(
            query, conn,
            geom_col="geom",
            params={"minx": minx, "miny": miny, "maxx": maxx, "maxy": maxy,
                    "regions": list(allowed_regions)},
        )

# Usage in dashboard
role   = st.session_state["user_role"]
bbox   = (-123.25, 49.18, -123.02, 49.32)   # Vancouver viewport
layers = load_infrastructure(role, bbox)

The cache key includes user_role, so a viewer and an analyst never share the same cached GeoDataFrame.

Jump to heading Step 5 — Gate UI components with the same permission checks

Once the data layer enforces the boundary, UI gating is a secondary safety signal — not the primary one. Add it anyway: it prevents confusing blank states and gives users clear feedback.

python
# Gate an analysis button
if require("run_spatial_analysis"):
    if st.button("Run Buffer Analysis (500 m)"):
        buffered = layers.to_crs("EPSG:3857").buffer(500).to_crs("EPSG:4326")
        st.write(f"{len(buffered)} features within buffer")
else:
    st.info("Spatial analysis tools require the analyst role or higher.")

# Gate a sensitive-layer toggle
if require("edit_sensitive_layers"):
    show_classified = st.toggle("Show classified infrastructure")
else:
    show_classified = False

Jump to heading Verification

Load the dashboard under three different test tokens (one per role) and confirm the query results and UI gating behave correctly:

python
# Paste into a test script — replace with a real PostGIS DSN and test tokens
import psycopg2
import geopandas as gpd

DSN  = "postgresql://test:test@localhost/geodata"
BBOX = (-123.25, 49.18, -123.02, 49.32)

for role, expected_regions in [
    ("viewer",  {"PUBLIC"}),
    ("analyst", {"PUBLIC", "RESTRICTED"}),
    ("admin",   {"PUBLIC", "RESTRICTED", "CLASSIFIED"}),
]:
    allowed = ROLE_REGIONS[role]
    with psycopg2.connect(DSN) as conn:
        gdf = gpd.read_postgis(
            "SELECT geom, region_code FROM infrastructure_grid "
            "WHERE region_code = ANY(%(r)s)",
            conn, geom_col="geom", params={"r": list(allowed)}
        )
    returned_regions = set(gdf["region_code"].unique())
    assert returned_regions <= expected_regions, (
        f"Role '{role}' received unexpected regions: {returned_regions - expected_regions}"
    )
    print(f"[PASS] {role}: {len(gdf)} features, regions {returned_regions}")

Expected output:

code
[PASS] viewer:  142 features, regions {'PUBLIC'}
[PASS] analyst: 389 features, regions {'PUBLIC', 'RESTRICTED'}
[PASS] admin:   521 features, regions {'PUBLIC', 'RESTRICTED', 'CLASSIFIED'}

Jump to heading Edge cases and gotchas

Cache-key isolation: bbox-only key leaks across roles, (user_role, bbox) key isolates themTwo side-by-side scenarios share the same process-wide cache. On the left, a cache keyed only on the bounding box maps both an analyst request and a later viewer request with the same viewport to a single entry, so the viewer is served the analyst's RESTRICTED features — a cross-role data leak. On the right, a cache keyed on the tuple (user_role, bbox) sends each role to its own entry, so the viewer only ever receives PUBLIC features and restricted geometry never crosses the role boundary.Key = (bbox)role ignored — entries collideanalystfirst caller, same bboxviewerlater, same bboxone entryPUBLIC +RESTRICTEDviewer served RESTRICTED⚠ LEAKKey = (user_role, bbox)role distinguishes entriesanalyst("analyst", bbox)viewer("viewer", bbox)entry APUBLIC + RESTRICTEDentry BPUBLIC onlyeach role gets its own entry✓ ISOLATED@st.cache_data is process-wide — the key, not the session, is what isolates role-scoped payloads.
  • Shared cache poisoning. @st.cache_data is process-wide, not session-scoped. If you cache a filtered GeoDataFrame using only the bounding box as the key, any user whose viewport matches will receive the first caller’s role-filtered result. Always include user_role (or a hash of the permission set) as an explicit function argument so the cache key is unique per role. The same user-scoped key discipline carries over to every shared store you front the dashboard with — see Caching Strategies & Async Performance Tuning for keying patterns that keep role-scoped payloads out of shared entries.

  • Framework reruns bypass startup logic. Streamlit re-executes the full script on every widget interaction. Role extraction wrapped in if "user_role" not in st.session_state runs only once, but any code outside that guard runs on every rerun. Ensure permission checks use session state rather than re-parsing headers on each rerun — header re-parsing on every interaction creates measurable latency at scale and, on some proxy configurations, headers are only forwarded on the initial HTTP upgrade request, not on WebSocket messages.

  • Panel’s pn.state requires explicit session hooks. Unlike Streamlit’s implicit session isolation, Panel shares pn.state.cache across all active sessions unless you scope entries to pn.state.user. Register a pn.state.on_session_destroyed callback to purge role data and close any open database cursors when a user disconnects, preventing stale permission sets from persisting into subsequent sessions.


Jump to heading FAQ

Can I use st.session_state to store the user's role securely?

You can store the resolved role in st.session_state as a read-only performance cache after extracting and validating it from a proxy header or JWT at startup. Treat it as a shortcut, never as the authoritative source. The IdP or reverse proxy remains the authority; if someone tampers with st.session_state through a serialisation exploit, your data-layer WHERE clauses are the backstop that prevents actual data exposure.

What happens if two users share the same @st.cache_data cache entry?

Without a user-specific cache key, Streamlit returns cached results from the first caller to any subsequent caller whose hashed function arguments match. For role-filtered GeoJSON this means a viewer could receive an analyst’s payload. Always include user_role or the permission frozenset hash as a function argument. In @st.cache_data you can pass custom hash_funcs if you need to hash non-serialisable objects.

Is it safe to hide sensitive map layers with CSS or conditional rendering alone?

No. Any layer data serialised to the browser — even hidden with CSS display:none — is visible in the browser’s network inspector and JavaScript heap. Server-side filtering at the PostGIS WHERE clause or tile-server proxy level is the only reliable boundary. See Caching Strategies & Async Performance Tuning for guidance on structuring tile server caches so role-scoped responses are not stored in shared public caches.


Back to Security Boundaries & Auth

Related