diff --git a/docker-compose-dev.yml b/docker-compose-dev.yml
index 6f7c5a3ff4..66cafa0afc 100644
--- a/docker-compose-dev.yml
+++ b/docker-compose-dev.yml
@@ -194,12 +194,16 @@ services:
dockerfile: Dockerfile
environment:
- PROWLER_MCP_TRANSPORT_MODE=http
+ # The API generates this key pair on first boot; reading the public half
+ # here is what lets the MCP server verify token signatures.
+ - DJANGO_TOKEN_VERIFYING_KEY_FILE=/home/prowler/.config/prowler-api/jwt_public.pem
env_file:
- path: .env
required: false
ports:
- "8000:8000"
volumes:
+ - ./_data/api:/home/prowler/.config/prowler-api:ro
- ./mcp_server/prowler_mcp_server:/app/prowler_mcp_server
- ./mcp_server/pyproject.toml:/app/pyproject.toml
- ./mcp_server/entrypoint.sh:/app/entrypoint.sh
diff --git a/docker-compose.yml b/docker-compose.yml
index 4b836aba6c..252bd7fec7 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -175,6 +175,11 @@ services:
restart: unless-stopped
environment:
- PROWLER_MCP_TRANSPORT_MODE=http
+ # The API generates this key pair on first boot; reading the public half
+ # here is what lets the MCP server verify token signatures.
+ - DJANGO_TOKEN_VERIFYING_KEY_FILE=/home/prowler/.config/prowler-api/jwt_public.pem
+ volumes:
+ - "./_data/api:/home/prowler/.config/prowler-api:ro"
env_file:
- path: .env
required: false
diff --git a/docs/getting-started/installation/prowler-mcp.mdx b/docs/getting-started/installation/prowler-mcp.mdx
index 5be469b5b2..e42de79404 100644
--- a/docs/getting-started/installation/prowler-mcp.mdx
+++ b/docs/getting-started/installation/prowler-mcp.mdx
@@ -222,6 +222,21 @@ Configure the server using environment variables:
| `PROWLER_API_KEY` | Prowler API key | Only for STDIO mode | - |
| `API_BASE_URL` | Custom Prowler API endpoint | No | `https://api.prowler.com/api/v1` |
| `PROWLER_MCP_TRANSPORT_MODE` | Default transport mode (overwritten by `--transport` argument) | No | `stdio` |
+| `DJANGO_TOKEN_VERIFYING_KEY` | Prowler API RS256 public key, used in HTTP mode to verify the signature of the bearer token. Escaped newlines (`\n`) are accepted so it fits in an env file | Recommended for HTTP mode | - |
+| `DJANGO_TOKEN_VERIFYING_KEY_FILE` | Path to that same public key on disk, read when `DJANGO_TOKEN_VERIFYING_KEY` is empty | No | - |
+
+
+In HTTP mode, without either of the two variables above the MCP server only
+checks that the bearer token is readable and unexpired, not that its signature
+is valid. The Prowler API still verifies every forwarded token, so a forged one
+is refused one hop later, but the MCP server will have acted on it first. Set
+one of them.
+
+Docker Compose wires this up already: the API writes the pair to
+`_data/api/jwt_public.pem` on first boot, and the `mcp-server` service mounts
+that directory read-only with `DJANGO_TOKEN_VERIFYING_KEY_FILE` pointing at it.
+A deployment that does not use Compose has to supply the public key itself.
+
```bash macOS/Linux
diff --git a/mcp_server/changelog.d/mcp-jwt-signature-verification.security.md b/mcp_server/changelog.d/mcp-jwt-signature-verification.security.md
index 21cb5f9ac3..2df011cacc 100644
--- a/mcp_server/changelog.d/mcp-jwt-signature-verification.security.md
+++ b/mcp_server/changelog.d/mcp-jwt-signature-verification.security.md
@@ -1 +1 @@
-JWT signatures in HTTP transport mode are verified against the Prowler API RS256 public key when `DJANGO_TOKEN_VERIFYING_KEY` is set, refusing forged, `alg: none` and HMAC-keyed tokens
+JWT signatures in HTTP transport mode are verified against the Prowler API RS256 public key, supplied through DJANGO_TOKEN_VERIFYING_KEY or a file path, refusing forged, alg none and HMAC-keyed tokens
diff --git a/mcp_server/prowler_mcp_server/prowler_app/utils/auth.py b/mcp_server/prowler_mcp_server/prowler_app/utils/auth.py
index e72a933af1..fb01970de8 100644
--- a/mcp_server/prowler_mcp_server/prowler_app/utils/auth.py
+++ b/mcp_server/prowler_mcp_server/prowler_app/utils/auth.py
@@ -1,6 +1,7 @@
import base64
import json
import os
+import pathlib
from datetime import datetime
import jwt
@@ -14,6 +15,11 @@ from prowler_mcp_server.lib.logger import logger
# declares `alg: none`, or an HMAC algorithm keyed with the public key, out.
JWT_ALGORITHMS = ["RS256"]
+VERIFYING_KEY_FILE_ENV = "DJANGO_TOKEN_VERIFYING_KEY_FILE"
+
+# Tolerated clock drift between the API that issues a token and this server.
+JWT_CLOCK_SKEW_SECONDS = 30
+
class ProwlerAppAuth:
"""Handles authentication for Prowler API using API keys or JWT tokens."""
@@ -35,7 +41,7 @@ class ProwlerAppAuth:
jwt_verifying_key.replace("\\n", "\n").strip() or None
if jwt_verifying_key
else None
- )
+ ) or self._read_verifying_key_file()
if mode == "stdio": # STDIO mode
# PROWLER_API_KEY is the current variable; PROWLER_APP_API_KEY is kept
@@ -86,6 +92,20 @@ class ProwlerAppAuth:
logger.warning(f"Failed to parse JWT token: {e}")
return None
+ @staticmethod
+ def _read_verifying_key_file() -> str | None:
+ """Public key from DJANGO_TOKEN_VERIFYING_KEY_FILE, which compose mounts from the API."""
+ path = os.getenv(VERIFYING_KEY_FILE_ENV, "").strip()
+ if not path:
+ return None
+ try:
+ return pathlib.Path(path).read_text().strip() or None
+ except OSError as error:
+ logger.warning(
+ f"Could not read {VERIFYING_KEY_FILE_ENV} at {path}: {error}"
+ )
+ return None
+
def _verify_jwt(self, token: str) -> dict:
"""Verify the signature and standard time claims; raise CredentialError otherwise."""
try:
@@ -96,6 +116,9 @@ class ProwlerAppAuth:
# The API's audience is deployment-specific and unknown here; the
# API checks it on every forwarded request.
options={"require": ["exp"], "verify_aud": False},
+ # The API and this server may sit on hosts with drifting clocks,
+ # and iat is only checked once a verifying key is configured.
+ leeway=JWT_CLOCK_SKEW_SECONDS,
)
except jwt.ExpiredSignatureError:
raise CredentialError("The token has expired")
diff --git a/mcp_server/tests/prowler_app/utils/test_auth.py b/mcp_server/tests/prowler_app/utils/test_auth.py
index 01a107a135..65e185543e 100644
--- a/mcp_server/tests/prowler_app/utils/test_auth.py
+++ b/mcp_server/tests/prowler_app/utils/test_auth.py
@@ -8,6 +8,7 @@ on them -- always pass them explicitly, as these tests do.
import base64
import json
+import time
import jwt
import pytest
@@ -125,6 +126,61 @@ async def test_http_mode_rejects_a_jwt_with_a_forged_signature(http_request_head
await auth.get_valid_token()
+async def test_http_mode_reads_the_verifying_key_from_a_file(
+ http_request_headers, monkeypatch, tmp_path
+):
+ """Compose mounts the API's public key; reading it is what enables verification."""
+ key_file = tmp_path / "jwt_public.pem"
+ key_file.write_text(JWT_VERIFYING_KEY)
+ monkeypatch.setenv("DJANGO_TOKEN_VERIFYING_KEY_FILE", str(key_file))
+ http_request_headers(authorization=f"Bearer {fake_jwt()}")
+
+ auth = ProwlerAppAuth(mode="http", jwt_verifying_key=None)
+
+ assert auth.jwt_verifying_key == JWT_VERIFYING_KEY.strip()
+ assert await auth.get_valid_token()
+
+
+async def test_http_mode_rejects_a_forged_jwt_when_the_key_comes_from_a_file(
+ http_request_headers, monkeypatch, tmp_path
+):
+ key_file = tmp_path / "jwt_public.pem"
+ key_file.write_text(JWT_VERIFYING_KEY)
+ monkeypatch.setenv("DJANGO_TOKEN_VERIFYING_KEY_FILE", str(key_file))
+ http_request_headers(
+ authorization=f"Bearer {fake_jwt(signing_key=ROGUE_JWT_SIGNING_KEY)}"
+ )
+
+ auth = ProwlerAppAuth(mode="http", jwt_verifying_key=None)
+
+ with pytest.raises(CredentialError, match="could not be verified"):
+ await auth.get_valid_token()
+
+
+async def test_http_mode_ignores_an_unreadable_verifying_key_file(
+ http_request_headers, monkeypatch, tmp_path
+):
+ monkeypatch.setenv(
+ "DJANGO_TOKEN_VERIFYING_KEY_FILE", str(tmp_path / "does-not-exist.pem")
+ )
+
+ auth = ProwlerAppAuth(mode="http", jwt_verifying_key=None)
+
+ assert auth.jwt_verifying_key is None
+
+
+async def test_http_mode_tolerates_a_token_issued_slightly_in_the_future(
+ http_request_headers,
+):
+ """Clock drift between the API host and this server must not reject fresh tokens."""
+ token = fake_jwt(iat=int(time.time()) + 10)
+ http_request_headers(authorization=f"Bearer {token}")
+
+ auth = ProwlerAppAuth(mode="http", jwt_verifying_key=JWT_VERIFYING_KEY)
+
+ assert await auth.get_valid_token() == token
+
+
async def test_http_mode_rejects_a_jwt_declaring_the_none_algorithm(
http_request_headers,
):