All /api/v1/* routes require a valid API key. The only unauthenticated endpoints
are / (API info) and /api/v1/health (health checks).
On first startup the backend generates a cryptographically random 64-character hex key,
writes it to <data_dir>/.api_key (mode 0600), and prints it to the console:
✓ API key authentication ready (key file: backend/data/.api_key)
On every subsequent start the same key is loaded from the file.
To rotate the key, delete the file and restart the backend:
rm backend/data/.api_key
python -m secuscan # a new key is generated on startupThe web UI does not fetch the key from the backend. You must configure it manually once after starting the backend:
- Read the key from the key file:
cat backend/data/.api_key
- Open the SecuScan UI → Settings → API Key section.
- Paste the key into the Backend API Key field and click Save.
The key is stored in the browser's localStorage under secuscan_api_key and
sent automatically on every subsequent API request via the X-Api-Key header.
No server-side session or cookie is involved — only the operator's browser retains
the key.
Read the key from the file and pass it in either of two header formats:
API_KEY=$(cat backend/data/.api_key)
# Option A — X-Api-Key header
curl -H "X-Api-Key: $API_KEY" http://localhost:8000/api/v1/plugins
# Option B — Bearer token
curl -H "Authorization: Bearer $API_KEY" http://localhost:8000/api/v1/pluginsSet SECUSCAN_API_KEY_FILE to point to a different key file path if you need to
store the key outside the default data directory:
export SECUSCAN_API_KEY_FILE=/run/secrets/secuscan_api_key
python -m secuscan| Path | Reason |
|---|---|
GET / |
API info / root |
GET /api/v1/health |
Health checks and monitoring |
All other /api/v1/* routes require a valid X-Api-Key or Authorization: Bearer
header. Requests without a valid key receive HTTP 401.
SecuScan uses a two-layer model for request identity:
- Authentication — the shared deployment API key (via
X-Api-KeyorAuthorization: Bearer) proves the caller is a valid SecuScan operator. - Authorization / Owner Scoping — the
X-User-Idheader identifies which workspace/user owns the data being accessed.
The X-User-Id HTTP header is the primary mechanism for multi-workspace isolation.
When present, resolve_owner_id() in auth.py transforms it into a stable owner
identity:
X-User-Id: alice → owner_id = "user:alice"
This owner_id is persisted on every task, finding, and report at creation time,
and compared on every read/delete operation. Without the header, all data belongs
to the single shared default workspace (owner_id = "default").
resolve_owner_id(request) applies these rules in priority order:
| Condition | Resulting owner_id |
|---|---|
X-User-Id header present and non-empty |
"user:" + header_value (whitespace trimmed) |
X-User-Id header missing or empty |
"default" |
The header value is not used verbatim — it is always prefixed with "user:" to
prevent confusion with the default owner. This prefix also makes it easy to
distinguish user-scoped data from system-scoped data in database queries.
# Alices workspace — only sees her tasks and findings
curl -H "X-Api-Key: $API_KEY" \
-H "X-User-Id: alice" \
http://localhost:8000/api/v1/tasks
# Bobs workspace — only sees his tasks and findings
curl -H "X-Api-Key: $API_KEY" \
-H "X-User-Id: bob" \
http://localhost:8000/api/v1/tasksBoth calls use the same shared API key for authentication. The X-User-Id
header drives the data isolation.
The X-User-Id header must be set by a trusted upstream auth proxy (SSO, API
gateway, or similar) before requests reach SecuScan. SecuScan itself does not
validate or authenticate this header — it trusts the value blindly. In a
single-user or air-gapped deployment, omit the header entirely to use the
default shared workspace.
This design protects against BOLA (Broken Object Level Authorization) by
ensuring that even if an operator guesses another users task or report ID,
the query is filtered by owner_id and returns nothing if the IDs do not
match the authenticated workspace.
| Aspect | API Key (X-Api-Key) |
X-User-Id |
|---|---|---|
| Purpose | Authenticates the deployment operator | Identifies the data owner |
| Scope | Global — valid for the entire deployment | Per-request — filters data |
| Generated by | SecuScan (64-char hex, persisted) | Upstream auth proxy |
| Default | Required for all /api/v1/* routes |
Absent = "default" workspace |
Owner scoping is not limited to tasks, findings, and reports. The same owner_id
gate also protects workflows and notification rules (issue #961). The columns
were introduced in backend/secuscan/migrations/003_add_owner_id.sql (issue #401).
List endpoints filter in SQL (... WHERE owner_id = ?), so a caller's list never
contains another owner's rows. Single-object task endpoints go through the
require_owned_task helper in backend/secuscan/routes.py, which deliberately
separates two failure modes:
| Situation | Status |
|---|---|
| Object does not exist at all | 404 Not Found |
| Object exists but is owned by someone else | 403 Forbidden |
Either way the object's contents are never returned to a non-owner. Keeping the two codes distinct makes the "exists but forbidden" case observable and testable.
An owner check is easy to add to one endpoint and forget on the next, and a single unscoped query silently re-opens the BOLA hole. So every owner-scoped endpoint needs a test proving a second user is refused — not merely that the owner succeeds. The existing suites are the template to copy when adding an endpoint:
testing/backend/integration/test_owner_authorization.py— tasks / findings / reports: list scoping, per-object403, missing-object404, and bulk-delete / clear only ever touching the caller's own rows.testing/backend/integration/test_workflow_owner_bola.py— workflows and notification rules.testing/backend/unit/test_auth_owner_resolution.py— the header →owner_idresolution itself.
A new owner-scoped endpoint is not "done" until a cross-owner test asserts the
non-owner gets 403 (or, for a list endpoint, simply does not see the row). Run the
suites with:
pytest testing/backend/integration/test_owner_authorization.py \
testing/backend/integration/test_workflow_owner_bola.py \
testing/backend/unit/test_auth_owner_resolution.py -q- The key file is written with mode
0600so only the process owner can read it. - Key comparison uses
secrets.compare_digestto prevent timing-oracle attacks. - There is no unauthenticated endpoint that exposes the key over the network. The only way to retrieve the key is to read the file from the filesystem where the backend is running — which requires local access to that machine.
- If the backend is not yet initialised (key file missing and startup not complete),
protected routes return
HTTP 503rather than401to distinguish between an uninitialised service and a bad credential. - API keys should never be transmitted to third-party webhook destinations.
- Operators should avoid embedding API credentials in webhook payloads, query parameters, or callback URLs.
- When webhook integrations are used, restrict outbound destinations to trusted services and use HTTPS for all webhook traffic.
- Webhook endpoints should be reviewed periodically to reduce SSRF exposure and accidental data disclosure risks.