SDEP is an API-first application designed for machine-to-machine (M2M) integrations.
The following security considerations apply.
- Identification
- Authentication and authorization
- Smaller platforms
- Audit log
- OWASP
- XSS, CSP, SQL, path (injection)
- CSRF
- Swagger UI
- File upload
- File download (Content-Disposition)
- Malware scanning
- Secrets
- Security headers
- Middleware ordering
- Security headers, DNS, TLS
- Rate limiting (throttling)
- Dependency version pinning
- Non-root containers
- Container image scans
- Authentication and authorization (details)
- Audit log (details)
This document applies to the application scope only (CI/CD-aspects are outside the scope of this repo).
Confidential machine clients must be identified upfront. This is assumed to be handled through established operational processes and is therefore outside the scope of this document.
For machine authentication, SDEP supports OAuth 2.0 with the Client Credentials Grant (grant_type=client_credentials).
- OAuth 2.0 with the Client Credentials Grant is the standard framework for trusted machine-to-machine (M2M) communication.
- It uses one underlying Client Credentials Flow, designed for machine-to-machine communication without an end-user context.
- It supports two types of Client Authentication, to authenticate against a same "token endpoint".
- On successful authentication, clients acquire a Bearer Access Token.
- The access token is used to invoke the actual (authorized) endpoints.
- The access token is short-lived, and should be refreshed frequently (programmatically).
Client authentication types are:
- Client ID & Secret
- The client sends a static symmetric shared secret to an Authorization Server (
client_secret_post/client_secret_basic).
- The client sends a static symmetric shared secret to an Authorization Server (
- Client-Signed JWT
- The client generates a short-lived JSON Web Token (JWT) and signs it using its own private key (
private_key_jwt). - An Authorization Server validates this request using the client's registered public key, offering a higher level of security since no shared secrets are transmitted over the wire.
- The client generates a short-lived JSON Web Token (JWT) and signs it using its own private key (
Client-signed JWT is regarded as the most secure:
- The OAuth 2.0 Security Best Current Practice (RFC 9700, section 2.5) recommends asymmetric client authentication (private-key JWT per RFC 7523) over shared secrets.
- The authorization server stores no symmetric key.
- There is no shared credential to leak.
- https://datatracker.ietf.org/doc/html/rfc9700#section-2.5
- Client-signed JWTs avoid distributing long-lived shared secrets to API clients.
- The client proves possession of its private key by signing a short-lived JWT for each token request.
- SDEP and Keycloak only need the corresponding public key to verify it.
- Key rotation becomes explicit.
- Private key material stays outside SDEP configuration.
- Interactive testing in the Swagger UI is supported.
- But, it requires a programmatic call to the
/tokenendpoint first, in order to acquire a bearer token.
- But, it requires a programmatic call to the
The Authorization Server for the reference implementation in this repository is Keycloak .
Specification
- Client Credentials Grant (RFC 6749, section 4.4) - https://datatracker.ietf.org/doc/html/rfc6749#section-4.4
- Client ID & Secret (RFC 6749, section 2.3.1) - https://datatracker.ietf.org/doc/html/rfc6749#section-2.3.1
- Client-Signed JWT (RFC 7523) - https://datatracker.ietf.org/doc/html/rfc7523
Implementation
SDEP-NL supports both authentication methods, via the same /token endpoint:
- Both methods are supported in the SDEP-NL Test and Pre-Production environments (TST, PRE)
- Only client-signed JWT is supported in the SDEP-NL Production environment (PRD)
Authentication
Authentication (obtaining a bearer token) proves who the client is, and can take place at the same /token endpoint, via:
- Client-signed JWT (
client_signed_jwt) - Client secret (
client_idandclient_secret)
The Swagger UI Authorize button follows the application configuration CLIENT_SECRET_AUTH_ENABLED.
- When
CLIENT_SECRET_AUTH_ENABLEDistrue:- The Swagger UI uses the OAuth 2.0 Client Credentials flow with client-secret authentication (client id and secret).
- When
CLIENT_SECRET_AUTH_ENABLEDisfalse:- The Swagger UI accepts a Bearer token that the operator obtained out of band with a client-signed JWT, because Swagger cannot sign a client JWT itself.
A Note on 2FA
Software, scripts, and servers cannot approve push notifications or type one-time passcodes. M2M authentication therefore uses non-interactive, cryptographic credentials, instead of human-style two-factor authentication (2FA).
Authorization
Authorization determines what the client is allowed to do.
- Based on the client's identity and pre-configured permissions, the server issues an access token containing specific scopes (roles).
- The client then presents this as a Bearer token during API calls to access protected resources.
Supported scopes (roles) are:
| Scope (role) | Purpose |
|---|---|
sdep_ca |
Competent Authority access |
sdep_str |
STR Platform access |
sdep_sta |
Statistics authority access |
sdep_lsa |
Listing screening authority access |
sdep_lma |
Listing monitoring authority access |
sdep_ama |
Activity monitoring authority access |
sdep_read |
Read operations |
sdep_write |
Write operations |
JWT Claims
JWT Claims used by the application:
| Claim | Maps to |
|---|---|
client_id |
Private client ID of the Platform or Competent Authority, linked to its public platformId or competentAuthorityId but separate from it |
client_name |
Platform or Competent Authority display name |
realm_access.roles |
Role-based authorization |
Smaller platforms can delegate SDEP API-invocation to a third party.
In that case, the platform arranges data submission with that third party. The third party becomes registered in SDEP.
The audit log, implemented in audit.py, logs "who did what, where, when, from where, and with what result".
Scope:
- (Yet) for technical management only (troubleshooting security, performance, ...)
- Enough context to reconstruct important actions
- No sensitive (personal) data
Implementation approach as follows.
Middleware-Based Audit Capture
A Starlette BaseHTTPMiddleware intercepts each request/response cycle and creates an audit record for every relevant interaction.
Non-Blocking Audit Writes
Audit records are persisted asynchronously using asyncio.create_task(), so audit logging does not block or delay the application response path.
Primary Output: audit_log Database Table
Audit records are written to the audit_log table in the application database.
- Append-only: records are inserted only; existing audit records are not updated or deleted.
- Error-resilient: audit write failures are logged, but they do not interrupt or fail the original request.
- Application-managed retention: database retention is handled by the application and may be shorter than external log retention.
Secondary Output: Structured JSON to Stdout
Each audit record is also emitted as a single-line structured JSON object to stdout.
Complementary Access Paths
Together, the database table and stdout output provide complementary access paths:
- Database audit log: convenient for application-level querying, investigation, and short-to-medium-term retention.
- Stdout: useful for real-time operational visibility, for example when viewing container logs.
- Stdout → external log management: stdout can be collected by the runtime environment and forwarded to external log tooling, such as an Elastic/Kibana-based stack, for centralized search and longer retention.
Deployment and Log Shipping Are Out of Scope
- This document defines how the application produces audit records and where it emits them.
- (Kubernetes) deployment details and external log management configuration are outside the scope of this repo.
For more details, see section Audit log (details).
Measures taken based on:
| ID | Subject | Explanation | Measure |
|---|---|---|---|
| A01:2025 | Broken Access Control | Unauthorized access to data or functions | Endpoints secured by OAuth 2.0 with JWT |
| A02:2025 | Security misconfiguration | Bad configurations, insecure defaults, environment mistakes | Externalized config (config.py) |
| A03:2025 | Software supply chain failures | Vulnerabilities in dependencies and external libraries | Container Image Scans (part of CI/CD) |
| A04:2025 | Cryptographic failures | Failures in encryption, key management | TLS terminated at the gateway; RS256 for JWT in the IAM (e.g. Keycloak), both CI/CD |
| A05:2025 | Injection | SQL, XSS, command, path injection | See XSS, CSP, SQL, Path (Injection) below |
| A06:2025 | Insecure design | Security not considered at design/architecture phase | Security by design (SDEP documentation) |
| A07:2025 | Authentication Failures | Weak login, session management or credential handling | Endpoints secured by OAuth 2.0 with JWT |
| A08:2025 | Software or data integrity failures | Failures in ensuring data or code integrity | Pydantic validation (application), source code control (CI/CD) |
| A09:2025 | Logging and alerting failures | Insufficient logging, monitoring or alerting | Audit log |
| A10:2025 | Mishandling of exceptional conditions | Improper handling of errors, edge cases, unexpected input | Exception handling (exception_handlers.py) |
| API1:2023 | Broken Object Level Authorization | Access to objects by manipulating IDs | Object-level scoping via JWT client_id in the service/CRUD layer |
| API2:2023 | Broken Authentication | Flawed identity verification | OAuth 2.0 with JWT (RS256), JWKS key rotation, token proxy timeout |
| API3:2023 | Broken Object Property Level Authorization | Exposing or modifying properties without authorization | Pydantic schemas (explicit fields, no mass assignment, frozen=True on auth models) |
| API4:2023 | Unrestricted Resource Consumption | Missing limits allowing DoS or cost exploitation | Upload, batch and pagination limits; rate limiting at deployment level |
| API5:2023 | Broken Function Level Authorization | Access to admin or restricted functions | Role-based endpoint protection via RequireRoles |
| API6:2023 | Unrestricted Access to Sensitive Business Flows | Automated abuse of business operations at scale | Not applicable: M2M only, client registration is process-controlled |
| API7:2023 | Server-Side Request Forgery | Fetching remote resources from user-supplied URLs | Not applicable: no user-supplied URL fetching, the IAM URL is server-configured |
| API8:2023 | Security Misconfiguration | Inappropriate hardening across the stack | Security headers, TLS, non-root container, no stack traces in responses |
| API9:2023 | Improper Inventory Management | No visibility of API assets and versions | Versioned API mounts (/api/{domain}/v1); auto-generated OpenAPI docs |
| API10:2023 | Unsafe Consumption of APIs | External APIs integrated without security controls | IAM (e.g. Keycloak) integration with TLS, timeout (10 s), JWKS caching |
XSS, CSP
A Cross-Site Scripting attack (XSS) has three phases:
- Input - the attacker injects malicious content (e.g.
<script>) - Storage / Reflection - the application returns that content to a user
- Output / Execution - the browser executes the script
SDEP mitigates these phases as follows.
Input is validated and rejected, to avoid injection.
- Server-side validation by FastAPI and Pydantic
- Rejects incorrect data types before the handler runs
Output is escaped, to avoid returning executable content:
- FastAPI automatically JSON-serializes all responses
- Special characters (
<,>,",') are escaped in JSON output
Route-specific Content Security Policy headers (CSP) are added, to avoid foreign script execution in browser:
- Implemented in the
headers.pymiddleware (tells the browser which sources are allowed to execute, and blocks everything else) - Applies a route-specific CSP (strict on all API and root paths, relaxed only on Swagger UI docs pages)
- Motivation: Swagger UI requires
'unsafe-inline'for its inline scripts andstyle=""attributes (nonces/hashes cannot cover the inline style attributes here) - https://thecodebuzz.com/content-security-policy-csp-swagger-ui-openapi/
SQL
A SQL injection attack manipulates database queries by inserting malicious SQL fragments into user input (e.g. ' OR 1=1 --), potentially reading, modifying, or deleting data.
SQL injection is mitigated by design through the technology stack:
- All database access uses SQLAlchemy ORM with its query builder API (
select(),insert(),update(),delete()) - All user-supplied values are passed as bound parameters - SQLAlchemy never interpolates values into SQL strings
- There are no raw SQL strings anywhere in the codebase (including audit log retention, which also uses the SQLAlchemy query builder)
- Pydantic validates and constrains all input before it reaches the database layer (type checks, max lengths, regex patterns)
Path
A path traversal attack manipulates file paths by inserting directory traversal sequences (e.g. ../../etc/passwd) into user input, potentially reading or overwriting files outside the intended directory.
Path traversal is mitigated by design through the application architecture:
- No filesystem operations on user-supplied input - uploaded files are read into memory and stored as binary blobs (
LargeBinary) in the database, not written to disk - The uploaded filename is stored as metadata in the database only; it is never used to construct filesystem paths
- All functional IDs (used in URL path parameters and form fields) are validated against a strict alphanumeric pattern (
^[A-Za-z0-9\-]+$incommon.py), which rejects path traversal characters (/,\,.,..) - JWT claims used as identifiers (
client_id) are validated against a similarly strict pattern (^[A-Za-z0-9._-]+$), which additionally permits.and_for Keycloak client naming while still rejecting path separators (/,\)
Cross-Site Request Forgery (CSRF) allows an attacker to trick a logged-in user into performing actions they didn't intend to do (via another website).
Cross-Site Request Forgery (CSRF) is not applicable for SDEP:
- SDEP uses stateless JWT bearer tokens in the
Authorizationheader, not cookies - a browser cannot automatically attach credentials to a forged request, so CSRF is not possible - Swagger UI authenticates via the same bearer token mechanism - no cookies are used, so CSRF-tokens and cookie attributes (
SameSite=Strict; Secure; HttpOnly) do not apply
See also security.py (OAuth2ClientCredentials).
The Swagger UI is intentionally served publicly by FastAPI without authentication, because the API itself is open source.
- As such, exposing the API documentation is considered an accepted and safe design decision rather than a security risk
- Potential risks related to public Swagger UI exposure and unauthorized access to API documentation are therefore not applicable in this context
Unauthorized usage of API endpoints is mitigated through the OAuth 2.0 Client Credentials flow using JWT bearer tokens.
File upload is implemented in areas.py (post_area).
File uploads are protected by:
- Format: only
.zipfiles are accepted (validated by filename extension and ZIP magic bytesPK\x03\x04); non-zip uploads return422 - Size: max 1 MiB (
MAX_FILE_SIZE = 1_048_576); oversized uploads return422 - Malware scanning: uploads are scanned with ClamAV before being accepted; infected files return
400. A scan that could not run (ClamAV unreachable, timeout, ClamAVERROR) returns503: the file is still refused, but the client learns to retry later instead of that its file is suspect - Filename sanitization at upload time: the uploaded filename is sanitized before it is stored in the database, using the shared
filename.pyutility (sanitize_upload_filename). Malicious filenames are never persisted. Sanitization:- Path separators are stripped (extracts basename from Unix
/and Windows\paths) - Control characters (C0 range
\x00-\x1f, CR, LF), double quotes, and backslashes are removed - Leading/trailing dots and whitespace are stripped; consecutive dots are collapsed
- If the resulting base name (before
.zip) is empty, the upload is rejected with422 - Filenames exceeding 64 characters after sanitization are rejected with
422
- Path separators are stripped (extracts basename from Unix
This works together with the download-time sanitization and RFC 5987 encoding, described in File Download (Content-Disposition). Defense-in-depth: malicious filenames are rejected at upload, re-sanitized at download, and safely encoded in the response header.
Area file downloads construct the Content-Disposition header using the shared filename.py utility. This complements the upload-time sanitization described in File Upload with two download-time measures:
Defense-in-Depth Re-Sanitization (sanitize_download_filename)
As a second line of defense, the download path re-sanitizes the stored filename before constructing the header.
- Re-sanitization can strip all characters, e.g. a filename consisting entirely of control characters
- Rather than emitting an empty
Content-Dispositionfilename, the function then returns the literal string"download", so the client always receives a usable filename - In practice this cannot occur: upload-time sanitization (see File Upload) already rejects such filenames - the fallback is a defensive guard only
RFC 5987 Encoding (content_disposition_header)
The header is encoded per RFC 5987, which defines how to include non-ASCII characters in HTTP header field parameters. Both filename= and filename*= are emitted:
Content-Disposition: attachment; filename="ascii-safe.zip"; filename*=UTF-8''percent-encoded.zip
filename="..."is the ASCII-safe fallback for legacy clients (non-ASCII characters are replaced with_)filename*=UTF-8''...is the RFC 5987 form for modern clients, using UTF-8 encoding with percent-encoded characters (viaurllib.parse.quote)- When both are present, compliant clients prefer
filename*=overfilename=(per RFC 6266, section 4.3)
RFC 5987 solves three problems:
- Non-ASCII filenames: characters like
é,ü, orñare percent-encoded instead of being silently dropped or causing encoding errors - Header injection prevention: percent-encoding neutralizes characters that would otherwise break HTTP header syntax (CR, LF,
",;) - Cross-browser compatibility: the dual
filename=/filename*=pattern ensures all clients receive a usable filename, regardless of their RFC 5987 support
The application uses ClamAV for malware scanning of uploaded files. Configuration is done via environment variables:
| Environment variable | Default | Description |
|---|---|---|
MALWARE_SCAN_ENABLED |
true |
Enables or disables upload malware scanning |
MALWARE_SCAN_CLAMAV_HOST |
"" |
ClamAV daemon host |
MALWARE_SCAN_CLAMAV_PORT |
3310 |
ClamAV daemon port |
MALWARE_SCAN_CLAMAV_TIMEOUT |
10 |
ClamAV scan timeout in seconds |
For local testing, Docker Compose starts ClamAV together with the rest of the stack:
make upThis uses docker-compose.yml through the top-level Makefile, and loads .env plus .env.extra when that optional override file exists.
The malware scan is tested automatically. To test malware detection manually, generate an EICAR test archive:
scripts/generate-eicar-zip.shUpload the generated file shown by the script through the CA area upload endpoint. The upload should be rejected with 400 because ClamAV detects the EICAR test signature.
To avoid data leaks, secrets are externalized in config.py.
To avoid misuse on various layers, HTTP-headers are hardened in main.py and headers.py:
| Layer | HTTP-header | Avoids |
|---|---|---|
| API version | API-Version: <major.minor.patch> on every response [1] |
Clients guessing which release serves them |
| Cache control (sensitive paths) | Cache-Control: no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0 on every API domain path |
Cached responses leaking authentication tokens or personal data |
| Content security | Content-Security-Policy: default-src 'self'; script-src 'self'; ... (CSP) |
Cross-site scripting (XSS), code injection, and data exfiltration |
| Cross-origin | Cross-Origin-Embedder-Policy: require-corp (COEP) [2] |
Cross-origin resource leaks via embedded content |
| Cross-origin | Cross-Origin-Opener-Policy: same-origin (COOP) |
Browsing context from cross-origin openers |
| Cross-origin | Cross-Origin-Resource-Policy: same-origin (CORP) |
Other origins loading SDEP responses |
| Encryption | Strict-Transport-Security: max-age=31536000; includeSubDomains; preload (HSTS) |
Plain (unencrypted) HTTP-sniffing |
| Frame protection | frame-ancestors 'none' |
Clickjacking |
| Frame protection | X-Frame-Options: DENY |
Clickjacking |
| MIME protection | X-Content-Type-Options: nosniff |
MIME-sniffing |
| Permissions | Permissions-Policy: geolocation=(), microphone=(), camera=(), ... [3] |
Unauthorized access to device features (geolocation, microphone, ...) |
| Referrer policy | Referrer-Policy: no-referrer |
Information leakage via Referer |
[1] The release version; the contract major is in the path (NLgov REST API Design Rules /core/version-header, see API).
[2] Consider unsafe-none when encountering 504 issues in deployment.
[3] Full value: geolocation=(), microphone=(), camera=(), payment=(), usb=(), magnetometer=(), gyroscope=().
Although CI/CD-related aspects are outside the scope of this repo, test results for SDEP-NL are as follows.
- SDEP-NL scores
A+on securityheaders.com - This validates that response headers provide adequate browser-side protection
CORS policy: no cross-origin access. SDEP grants no origin access to the API. There is no CORS middleware and no Access-Control-Allow-Origin header, so a browser denies every cross-origin request by default, the most restrictive outcome. This is the deliberate policy, not an omission (NLgov REST API Design Rules /core/transport/cors, see API):
- SDEP is a backend API consumed by server-side clients using machine-to-machine (M2M) OAuth 2.0 tokens
- Server-to-server calls do not go through a browser
- So CORS is never triggered
- Swagger UI is served from the same origin as the API
- So its requests are same-origin and CORS does not apply
- Should a browser client ever need access (e.g. a monitoring dashboard on another origin), add Starlette's
CORSMiddlewarewith that one origin allowlisted; never*
Starlette processes middleware LIFO (last added = outermost = runs first). In main.py:
- SecurityHeadersMiddleware (outermost) - added last, runs first
- AuditLogMiddleware (inner) - added first, runs inside security headers
Although CI/CD-related aspects are outside the scope of this repo, additional test results for SDEP-NL are as follows.
- SDEP-NL scores
100%on internet.nl - This validates that transport-level security is correctly applied (poor basic configuration would increase the attack surface)
Rate limiting helps protect against brute-force attacks and abuse. The risk is highest on unauthenticated endpoints such as /token, where an attacker could attempt credential stuffing at network speed.
Rate limiting is typically applied per client IP address, and is enforced at the deployment or infrastructure layer, e.g.:
- Kubernetes Gateway API
- HAProxy load balancer
- IAM (e.g. Keycloak) authorization server
These deployment-specific concerns are outside the scope of this repository.
Dependencies are declared with flexible lower bounds (>=) in pyproject.toml and locked to exact versions in uv.lock.
Why >= instead of == in pyproject.toml
pyproject.tomldeclares the minimum acceptable version of each dependency (the intent)uv.lockrecords the exact resolved version of every package, including transitive dependencies (the pin)- The Dockerfile installs with
uv sync --frozen, which enforces the lock file exactly - no version can drift at build time - Using
==inpyproject.tomlwould duplicate what the lock file already does, while making legitimate upgrades harder and not covering transitive dependencies - Security remediations follow the same rule: the
>=floor is raised to the version that fixes the CVE (for transitive packages via[tool.uv]constraint-dependencies), whileuv.lockkeeps pinning the exact shipped version
Docker Base Images
- The Python base image is pinned to a minor version (
python:3.14-slim) viaARG PYTHON_IMAGE - The
uvinstaller is pinned to a specific release (ghcr.io/astral-sh/uv:0.12.21) - PostgreSQL, Keycloak and ClamAV versions are externalized via environment variables in
docker-compose.yml
Keeping Dependencies Up to Date
- Running
uv lock --upgraderegenerates the lock file with the latest compatible versions - The lock file should be committed and reviewed as part of the normal change process
The Docker container runs as a non-root user (app), following the principle of least privilege. This limits the impact of a container escape or application compromise.
To minimize exposure to Common Vulnerabilities and Exposures (CVEs), the reference implementation includes container image scanning as part of CI/CD:
- Continuously monitor and remediate Critical and High severity CVEs.
- Implement remediation according to a "comply (fix) or explain" policy.
The reference implementation provides two CVE checks:
make test-cvebuilds the image, scans it with Trivy, and compares the findings with the CVE allowlist indocs/CVE_EXPLAINS.md. It fails when:- The scan reports a CVE the allowlist does not justify
- The allowlist still lists a CVE the scan no longer reports
- A listed package or severity differs from what the scan reports
- The same CVE is listed twice
make test-cve-offlinescans nothing. It feeds prepared reports to the comparison script, to confirm it still catches each of those cases, and checks that the allowlist identifiers have a plausible year. It needs no image, so it is the fast check and runs inmake all, while the image scan runs inmake ci-gate.
The image scan reads a vulnerability database that upstream rebuilds every few hours.
- Trivy caches that database locally, and keeps it until its recorded next-update time (up to a day later)
- So a cached local scan and a fresh pipeline scan can report different CVEs and severities for the same image
make test-cvetherefore drops the cached database before every scan and downloads the current one, matching the pipeline- Set
TRIVY_SKIP_DB_REFRESH=1to reuse the cached database when working offline or iterating quickly - the result may then no longer match the pipeline
Note:
docs/CVE_EXPLAINS.mdis intentionally not committed. Each EU member state implementing an SDEP is responsible for maintaining its own CVE allowlist and remediation process within its CI/CD pipeline.
Running the scanner and validating its report should be regarded as blocking CI/CD checks (make ci-gate).
Each EU member state implementing an SDEP is responsible for monitoring and remediating CVEs within its own CI/CD.
SDEP interacts with IAM (e.g. Keycloak) in two distinct ways: token issuance (active HTTP call) and token validation (local signature verification using cached public keys).
Token Issuance (Proxy to Keycloak)
When an external client needs a JWT, it calls SDEP's /api/auth/v1/token endpoint.
SDEP acts as a proxy:
- It takes either the client's
client_id+client_secret(from HTTP Basic Auth or form body) or itsclient_id+client_signed_jwt - It maps it to Keycloak's OAuth
private_key_jwtrequest fields - It forwards the token request to Keycloak's token endpoint at
/realms/sdep/protocol/openid-connect/token - It returns the resulting JWT
This is a synchronous request-response - every token request hits Keycloak directly.
See auth.py.
Token Validation (Client-Signed JWT)
On every subsequent API call, the client sends the JWT as a Bearer token. SDEP verifies the token signature locally - without calling Keycloak on every request - using the JSON Web Key Set (JWKS) protocol:
- Keycloak signs JWTs with its private RSA key and publishes the corresponding public keys at the JWKS endpoint:
/realms/sdep/protocol/openid-connect/certs PyJWKClientfetches that key set and caches it in-memory- For each incoming request,
get_signing_key_from_jwt(token)reads the JWT'skid(key ID) header, finds the matching public key from the cached set, and verifies the RS256 signature - The decoded payload is returned - no HTTP call to Keycloak needed
See security.py.
Audience Validation
A JWT can contain an aud (audience) claim that says which application the token was issued for. When an application checks aud, it rejects tokens that were meant for a different service - even if the signature is valid. This prevents a token issued for Service A from being reused against Service B.
Two different JWTs occur in SDEP, and aud is treated differently in each. Which client authentication method was used does not change this: both client secret and client-signed JWT lead to the same kind of access token.
| JWT | Sent to | aud checked |
Checked by |
|---|---|---|---|
Client assertion (client_signed_jwt) |
/token, to authenticate |
Yes | Keycloak |
Access token (Bearer) |
The API, to authorize the call | No | - |
Client Assertion
With client-signed JWT (private_key_jwt), the client signs a short-lived assertion whose aud is the authorization server's token endpoint. SDEP does not inspect that assertion: it forwards it to Keycloak as client_assertion, and Keycloak validates the signature against the registered public key and the aud against its own token endpoint. An assertion signed for a different token endpoint is rejected with HTTP 401.
This audience binding is required by RFC 7523 and is what stops an intercepted assertion from being replayed against another authorization server. It is also why each environment configures its own CLIENT_SIGNED_JWT_AUDIENCE, matching the public issuer URL of that environment's Keycloak.
Access Token
The bearer token that SDEP itself verifies on each API call is not audience-checked. The reason is practical:
- Keycloak does not include an
audclaim in the client credentials tokens it issues to SDEP clients by default - If SDEP started requiring
aud, every existing client would be rejected, until the Keycloak configuration is updated to include it
SDEP is the only application in the Keycloak realm, so the aud claim always originates from SDEP itself. Therefore this has no security impact.
The validation guarantees for the access token are therefore:
- RS256 signature verification using Keycloak JWKS (proves the token was issued by Keycloak and has not been tampered with)
- Expiry (
exp) verification (rejects tokens that are no longer valid) - No
audverification (a valid Keycloak token for another service in the same realm would be accepted)
JWKS Key Rotation (5-Minute TTL)
PyJWKClient is configured with cache_jwk_set=True and lifespan=300 (5 minutes). This ensures that when Keycloak rotates or revokes signing keys, SDEP picks up the changes within at most 5 minutes - without requiring a restart. The alternative (@lru_cache) would cache keys indefinitely, meaning rotated or revoked keys would never be refreshed until the process was restarted.
| Concern | How it's addressed |
|---|---|
| Performance | 99.9% of requests use cached keys - no network call to Keycloak |
| Key rotation | New keys are picked up within 5 minutes |
| Key revocation | Revoked keys stop being trusted within 5 minutes |
| Thread safety | _get_jwks_client() uses double-checked locking to ensure exactly one PyJWKClient instance is created across threads |
Request Authentication Flow
Client request with Bearer token
→ OAuth2ClientCredentials extracts the token from the Authorization header
→ verify_bearer_token() calls validate_jwt_token()
→ _get_jwks_client() returns the singleton PyJWKClient
→ client.get_signing_key_from_jwt(token) matches the JWT kid to a cached public key
→ jwt.decode() verifies signature + expiry using that key
→ get_parsed_token() extracts realm_access.roles, client_id, client_name
→ RequireRoles checks roles against endpoint requirements
→ Endpoint handler executes
See auth_dependencies.py.
Audit Fields
For each request that matters, capture:
| Field | Source | Description | Answers |
|---|---|---|---|
| timestamp | Server clock | UTC, server default now() |
When |
| requestId | Generated | UUID4 correlation ID | - |
| roles | JWT realm_access.roles |
Verified roles, or null when no token was authenticated (401, or unauthenticated endpoints) |
Who |
| resourceType | Derived from path | Entity type, e.g. area, listing, activity |
Where |
| action | Derived from method + path | Semantic action verb, e.g. create |
What |
| httpMethod | Request | HTTP method (GET, POST, DELETE) |
What |
| path | Request | Request path, e.g. /api/ca/v1/areas |
Where |
| httpStatusCode | Response | HTTP status code | Result |
| statusCode | Derived from httpStatusCode | OK if httpStatusCode < 400, else NOK |
Result |
| durationMs | Calculated | Request processing time in milliseconds | - |
Role Extraction - Only From Verified Tokens
The audit middleware reads roles from the JWT payload. The auth dependency (verify_bearer_token) stashes that payload on request.state.jwt_payload, after signature and expiry verification.
- Tokens that fail verification never reach
request.state, so forged tokens cannot pollute the audit trail - The middleware does not re-decode the token, avoiding a duplicate signature check per audited request
- The 401 vs 403 distinction is encoded in the
httpStatusCodecolumn - The
rolescolumn carries the verified role set when one is available, andnullotherwise - Audience validation remains disabled, as described above:
audis not enforced until Keycloak token configuration supports it
| Scenario | What happens | roles in audit log |
|---|---|---|
| Valid JWT, authorized (2xx) | Auth dependency verifies the token and stashes the payload on request.state |
Verified roles from token |
| Valid JWT, missing required role (403) | Token verified by verify_bearer_token; RequireRoles then rejects on insufficient role |
Verified roles from token |
| Forged, tampered, or expired JWT (401) | Auth dependency rejected the token before the handler ran; no payload on request.state |
null |
No JWT (e.g. /token endpoint) |
No bearer credentials presented | null |
Action Mapping
The middleware derives a semantic action and resource type from the HTTP method and request path:
| Method | Path pattern | Resource type | Action |
|---|---|---|---|
| POST | /api/ca/v*/areas |
area |
create |
| GET | /api/ca/v*/areas |
area |
list |
| GET | /api/ca/v*/areas/count |
area |
count |
| GET | /api/ca/v*/areas/{id} |
area |
read |
| DELETE | /api/ca/v*/areas/{id} |
area |
delete |
| GET | /api/str/v*/areas |
area |
list |
| GET | /api/str/v*/areas/count |
area |
count |
| GET | /api/str/v*/areas/{id} |
area |
read |
| POST | /api/str/v*/listings/bulk |
listing |
create_bulk |
| POST | /api/lsa/v*/listing-screenings/bulk |
listing |
screen_bulk |
| POST | /api/str/v*/listing-acknowledgements/bulk |
listing |
acknowledge_bulk |
| GET | /api/{str,lsa,ca,lma,sta}/v*/listings |
listing |
list |
| GET | /api/{str,lsa,ca,lma,sta}/v*/listings/count |
listing |
count |
| POST | /api/str/v*/activities/bulk |
activity |
create_bulk |
| GET | /api/ca/v*/activities |
activity |
list |
| GET | /api/ca/v*/activities/count |
activity |
count |
| GET | /api/sta/v*/activities |
activity |
list |
| GET | /api/sta/v*/activities/count |
activity |
count |
| GET | /api/ama/v*/activities |
activity |
list |
| GET | /api/ama/v*/activities/count |
activity |
count |
| POST | /api/auth/v*/token |
auth |
token |
| GET | /api/ping |
system |
ping |
Unmatched paths fall back to action unknown.
Example
| id | timestamp | request_id | roles | resource_type | action | http_method | path | http_status_code | status_code | duration_ms |
| --- | ----------------------------- | ------------ | ---------------------------- | ------------- | ------ | ----------- | ---------------- | ---------------- | ----------- | ----------- |
| 20 | 2026-03-23 15:03:38.519686+00 | a34e8a0e-... | sdep_write,sdep_ca,sdep_read | system | ping | GET | /api/ping | 200 | OK | 1 |
| 21 | 2026-03-23 15:03:39.864974+00 | 7bccb30b-... | sdep_write,sdep_ca,sdep_read | area | create | POST | /api/ca/v1/areas | 201 | OK | 33 |
| 22 | 2026-03-23 15:03:39.947615+00 | f357d78c-... | sdep_write,sdep_ca,sdep_read | area | create | POST | /api/ca/v1/areas | 201 | OK | 27 |
| 23 | 2026-03-23 15:03:40.02963+00 | 02294cf4-... | sdep_write,sdep_ca,sdep_read | area | create | POST | /api/ca/v1/areas | 201 | OK | 18 |
Skip List
The following paths are not audited (high-frequency, low-value):
/(root)/favicon.ico(browsers request this automatically; the application does not serve a favicon)/api/docs(landing page)/api/health- The
/openapi.jsonand/docspath of every API version (/api/auth/v1,/api/ca/v1andv2,/api/str/v1andv2,/api/lsa/v2,/api/lma/v2,/api/ama/v1,/api/sta/v1andv2), plus/api/openapi.json,/api/ping/openapi.jsonand/api/ping/docs - The list lives in
backend/app/security/audit.py(SKIP_PATHS); the API version paths are derived from the domain registry (API_DOMAINS), so a new API version is skipped without an edit there
Retention of the Database
For the database table, expired audit log rows are automatically deleted by a background task that runs every hour.
- The retention period is configurable via the
AUDITLOG_RETENTIONenvironment variable (default: 1 day). - Deletion is batched (1,000 rows per batch) to avoid long-running transactions.
The retention logic in audit_retention.py is split into two functions with distinct responsibilities:
delete_old_audit_logsdoes the actual work.audit_log_cleanup_loopis the scheduler that ensures that work runs repeatedly for the lifetime of the application.
| Function | Responsibility | Invocation |
|---|---|---|
delete_old_audit_logs(retention_days) |
One-shot deletion of rows older than retention_days [1] |
Each cycle of audit_log_cleanup_loop; also standalone in scripts and tests |
audit_log_cleanup_loop(retention_days, interval_seconds) |
Infinite scheduling loop around delete_old_audit_logs [2] |
An asyncio.Task in the FastAPI lifespan of main.py [3] |
[1] Deletes in batches of 1,000 and returns the total number of deleted rows. A pure async function that runs to completion; it does not loop or sleep.
[2] Runs one deletion, sleeps interval_seconds (default 3,600 s = 1 hour), repeats until cancelled. Exceptions are caught and logged, so one failed cycle does not kill the loop.
[3] Started when the application boots, cancelled (task.cancel()) when it shuts down.
Retention of Stdout
For stdout, retention is assumed to be part of the deployment environment (out of scope of this repo).