Securing Municipal API Endpoints for Third-Party Integrations
Securing municipal API endpoints for third-party integrations means letting known contractors, GIS vendors, and compliance platforms call your permit services without exposing them to leaked keys, spoofed identities, or hostile payloads. This guide builds on Implementing Role-Based Access for Clerk Portals, part of the broader Core Architecture & Code Taxonomy for Municipal Permits reference: where that parent guide governs how internal clerks and inspectors are scoped, this walkthrough extends the same authorization model outward to callers on the public internet.
The focused task here is narrow and high-stakes: let a known third party submit and read permit data programmatically, without ever letting an unknown caller — or a leaked API key — reach business logic. Get this wrong and the failure mode is not a 500 error; it is a contractor reading another contractor’s submissions, an unauthenticated client mutating an inspection result, or a recursive JSON payload exhausting a constrained municipal server during a permit-deadline traffic spike. The inputs are untrusted: TLS handshakes from arbitrary clients, bearer tokens of unknown provenance, and JSON bodies of unbounded shape. The outputs must be deterministic: a verified caller identity, a least-privilege scope set, a validated payload, and a tamper-evident audit record for every request. This page implements that path in four discrete steps with production-ready Python, then covers the failure patterns and logging that municipal compliance officers actually review.
Permalink to this section Step 1: Terminate mTLS and Extract a Verified Caller Identity
API keys alone are insufficient for municipal systems: they are bearer secrets vulnerable to credential stuffing and replay, with no cryptographic binding to the requesting entity. Terminate mutually authenticated TLS (mTLS) at a hardened reverse proxy, validate the client certificate against a municipal Certificate Authority, and forward the verified subject as a trusted header. The application layer must then treat that header as authoritative only when the request arrives from the proxy’s network, never from the public interface.
from fastapi import Header, HTTPException, Request, status
# The proxy (nginx/Envoy) verifies the client cert against the municipal CA and
# sets X-Client-Identity to the cert subject DN. We must reject this header if it
# arrives from anywhere except the trusted proxy, or a client could spoof identity.
TRUSTED_PROXY_HOSTS: frozenset[str] = frozenset({"10.0.0.10", "10.0.0.11"})
def verified_client(
request: Request,
x_client_identity: str | None = Header(default=None),
) -> str:
if request.client is None or request.client.host not in TRUSTED_PROXY_HOSTS:
# Direct hit on the app port: identity header cannot be trusted.
raise HTTPException(status.HTTP_403_FORBIDDEN, "untrusted network path")
if not x_client_identity:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "client certificate required")
return x_client_identity # e.g. "CN=acme-roofing,O=Vendor,serial=..."
Binding identity to a certificate the proxy already validated means a stolen bearer token is useless without the matching private key presented at the TLS layer.
Permalink to this section Step 2: Verify Short-Lived JWTs and Map Identity to Scopes
Third-party callers authenticate with the OAuth 2.0 Client Credentials grant and receive short-lived JWTs. Cap token lifetime at fifteen minutes, sign with an asymmetric algorithm (RS256 or ES256), and enforce strict aud and iss validation so a token minted for a different audience or tenant cannot be replayed against your endpoints. Map the verified certificate subject to a least-privilege scope set using the same isolation principles applied to clerk roles — a GIS vendor that reads parcel geometry should never hold a permits:write scope.
import jwt # PyJWT
from jwt import PyJWKClient
JWKS = PyJWKClient("https://idp.internal/realms/permits/jwks", cache_keys=True)
EXPECTED_ISS = "https://idp.internal/realms/permits"
EXPECTED_AUD = "permit-api"
# Least-privilege: certificate subject → allowed scopes. Source of truth lives
# alongside the clerk-portal role matrix so external and internal access agree.
SCOPE_BINDINGS: dict[str, frozenset[str]] = {
"CN=acme-roofing,O=Vendor": frozenset({"permits:read", "permits:write"}),
"CN=county-gis,O=Vendor": frozenset({"parcels:read"}),
}
def authorize(token: str, subject: str, required: str) -> frozenset[str]:
key = JWKS.get_signing_key_from_jwt(token).key
claims = jwt.decode(
token, key,
algorithms=["RS256", "ES256"], # never accept "none" or HS* here
audience=EXPECTED_AUD,
issuer=EXPECTED_ISS,
options={"require": ["exp", "iat", "aud", "iss"]},
leeway=30, # tolerate small NTP drift, no more
)
granted = SCOPE_BINDINGS.get(subject, frozenset()) & set(claims.get("scope", "").split())
if required not in granted:
raise HTTPException(status.HTTP_403_FORBIDDEN, f"missing scope: {required}")
return granted
Cache the JSON Web Key Set with a TTL under five minutes (PyJWKClient handles this) — excessive JWKS polling introduces latency spikes and risks identity-provider rate limiting, while a longer TTL delays key-rotation propagation.
Permalink to this section Step 3: Enforce Strict Schema and Bound Payload Size
Reject malformed or hostile payloads before they reach business logic or the database. Permit submissions reference zoning overlays, structural references, and inspection checklists as deeply nested arrays — exactly the shape attackers exploit for JSON injection or denial-of-service via payload expansion. Define the contract with Pydantic V2 in strict mode so type coercion is off and unexpected fields raise immediately. Pair it with a pre-routing guard that rejects oversized bodies before they are ever parsed, and keep the schema aligned with your canonical JSON schemas for building permits.
from datetime import datetime
from pydantic import BaseModel, ConfigDict, Field
MAX_BODY_BYTES = 2 * 1024 * 1024 # 2 MiB — guards the GC during large JSON parses
class InspectionItem(BaseModel):
model_config = ConfigDict(strict=True, extra="forbid") # no coercion, no stray keys
code: str = Field(min_length=1, max_length=32)
passed: bool
class PermitSubmission(BaseModel):
model_config = ConfigDict(strict=True, extra="forbid")
parcel_id: str = Field(pattern=r"^[0-9A-Z\-]{6,24}$")
submitted_at: datetime # tz-aware UTC enforced below
items: list[InspectionItem] = Field(max_length=200) # bound recursion/expansion
async def guard_size(request: Request) -> None:
declared = int(request.headers.get("content-length", 0))
if declared > MAX_BODY_BYTES:
raise HTTPException(status.HTTP_413_REQUEST_ENTITY_TOO_LARGE, "payload too large")
The max_length on nested lists and the explicit content-length check together bound deserialization cost, so a single crafted request cannot exhaust memory on a shared municipal host.
Permalink to this section Step 4: Write a Tamper-Evident Audit Record
State records-retention mandates require a tamper-evident trail for every permit modification and third-party exchange. Emit one structured, append-only entry per request, hash-chained to its predecessor so any deletion or edit breaks the chain and is detectable during a public-records audit. Redact secrets at write time — never persist raw tokens, certificate serials, or internal routing headers.
import hashlib
import json
from typing import Any
_last_hash = "0" * 64 # seed; in production, load the previous chain head at startup
def audit(subject: str, scopes: frozenset[str], path: str, status_code: int,
extra: dict[str, Any] | None = None) -> str:
global _last_hash
entry = {
"ts": datetime.now(tz=__import__("datetime").timezone.utc).isoformat(),
"subject": subject,
"scopes": sorted(scopes),
"path": path,
"status": status_code,
"prev": _last_hash,
**(extra or {}),
}
body = json.dumps(entry, sort_keys=True, separators=(",", ":")).encode()
entry["hash"] = _last_hash = hashlib.sha256(body).hexdigest()
# Append to WORM storage / stream to the SIEM; do not buffer-and-lose on crash.
return entry["hash"]
This record is what compliance officers query for open-records requests, so its schema should export cleanly without exposing PII or internal topology.
Permalink to this section Parameter and Flag Reference
| Setting | Recommended value | Rationale for permit endpoints |
|---|---|---|
| JWT algorithm | RS256 or ES256 |
Asymmetric signing; reject none and HS* to prevent key-confusion forgery |
Token lifetime (exp) |
≤ 15 min | Caps the blast radius of a leaked token between rotations |
leeway on decode |
30 s | Tolerates minor NTP drift without accepting stale tokens |
| JWKS cache TTL | < 5 min | Balances key-rotation propagation against IdP rate limiting |
Pydantic strict |
True |
Disables silent type coercion ("5" → 5) that masks bad input |
Pydantic extra |
"forbid" |
Rejects unexpected fields instead of silently dropping them |
| Max body size | 2 MiB | Stops payload-expansion DoS before JSON parsing allocates |
Nested list max_length |
200 | Bounds recursion/expansion cost on constrained hosts |
| p95 latency budget | < 100 ms | Sustains throughput during permit-deadline submission windows |
Permalink to this section Common Failure Patterns and Fixes
Permalink to this section Spoofed identity header on the direct app port
If the application port is reachable without traversing the proxy, any client can send its own X-Client-Identity. Bind to localhost or a private interface and enforce the TRUSTED_PROXY_HOSTS check from Step 1; never derive identity from a header on a publicly bound socket.
Permalink to this section aud/iss validation silently disabled
Calling jwt.decode(..., options={"verify_aud": False}) during local testing and shipping it is a recurring breach vector — a token minted for another service then validates here. Make aud, iss, exp, and iat required (as in Step 2) and assert it in a unit test so the guard cannot regress.
Permalink to this section Clock skew rejecting valid tokens
Short fifteen-minute tokens are sensitive to NTP drift between the IdP and the API host. Symptoms are intermittent 401s that correlate with one node. Synchronize via NTP, keep leeway at 30 seconds, and alert when a host’s skew exceeds it rather than widening the window.
Permalink to this section Type coercion smuggling bad data past validation
Without strict=True, Pydantic coerces "200" into 200 and "true" into a boolean, letting malformed third-party fields pass. Enable strict mode and extra="forbid" on every externally facing model, and fixture-test a rejection case.
Permalink to this section Audit chain head lost on restart
If _last_hash re-seeds to zeros on every deploy, the chain fragments and integrity verification fails across the boundary. Persist the previous chain head and load it at startup; treat a mismatch on boot as an incident, not a warning.
Permalink to this section Audit and Logging Guidance
Log one entry per request — authenticated subject, granted scopes, route, status, and the schema-validation outcome — to append-only (WORM) storage or a SIEM with hash-chained entries, as shown in Step 4. Record validation failures with the same rigor as successes: a burst of 403s from one vendor subject is the earliest signal of a compromised credential or a misconfigured integration. Apply redaction at ingestion so raw tokens, certificate serials, and internal hostnames never persist, and align the export schema with your open-records format so a clerk can satisfy a public-records request without a custom extract. Keep these logs for the full state-mandated retention period, and verify the hash chain on a schedule so tampering surfaces in days, not at audit time. When third parties submit in bulk, route their requests through the same controls described in error handling and retry logic for ingestion pipelines so a rejected payload is queued and traceable rather than silently dropped.
During backend maintenance or unexpected outages, external callers should receive predictable degradation rather than cascading failures — pair these endpoints with fallback routing for legacy system downtime so submissions queue or return a cached compliance state without data loss.
Permalink to this section FAQ
Permalink to this section Why not just issue long-lived API keys to trusted vendors?
A long-lived key is a single bearer secret with no cryptographic binding to the caller; once leaked it grants full access until manually revoked. mTLS plus short-lived JWTs binds access to a private key the vendor holds and a token that expires in minutes, so a leaked credential is useless or short-lived.
Permalink to this section Do I need both mTLS and JWTs, or is one enough?
They defend different layers. mTLS proves which machine is connecting and is validated before any application code runs; the JWT carries what that caller may do (scopes) and is cheap to rotate. Using both means a stolen token without the matching client certificate cannot reach your handler.
Permalink to this section How do external vendor scopes stay consistent with internal clerk roles?
Keep the scope bindings beside the clerk-portal role matrix in the parent role-based-access guide so both derive from one source of truth. A change to what “read parcels” means then applies identically to staff and to third-party consumers, preventing drift between internal and external access.
Permalink to this section What latency should I budget for all this verification?
Certificate validation happens once at the proxy, JWKS keys are cached, and Pydantic strict validation on a bounded payload is sub-millisecond. The full path comfortably holds a p95 under 100 ms; offload anything heavier (signature checks, schema parsing) to compiled middleware or edge workers if a constrained host struggles.
Permalink to this section Related
- Implementing Role-Based Access for Clerk Portals — the parent guide defining the internal scope model these endpoints extend.
- Configuring Circuit Breakers for Permit Database Timeouts — keep third-party calls resilient when the backend stalls.
- Designing JSON Schemas for Building Permits — the canonical contract your strict Pydantic models should mirror.
- Cache Warming Strategies for Permit Lookup APIs — sustain read latency for high-volume vendor lookups.