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.

Request authorization data-flow for a third-party permit API integration An untrusted client completes an mTLS handshake that crosses a trust boundary from the public internet into the internal network. A reverse proxy validates the client certificate against the municipal CA and injects a verified X-Client-Identity header. The request flows left to right through JWT verification of the audience, issuer, and expiry claims plus a least-privilege scope check, then strict Pydantic schema validation with a payload-size guard, and finally the permit handler. Each enforcement stage writes one hash-chained, append-only entry to the audit log capturing the subject, granted scopes, route, and status for every request — including rejections. mTLS · trust boundary verified identity scoped caller valid payload Untrusted client public internet mTLS proxy validate cert vs CA inject X-Client-Identity JWT verify aud · iss · exp least-privilege scopes Strict schema Pydantic V2 strict size guard Permit handler business logic one entry per request Append-only audit log · hash-chained, tamper-evident subject · granted scopes · route · status — logged for successes and rejections alike

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.