Skip to content

fix: fail startup when AUTH_ENABLED=true and JWT_SECRET is unset (closes silent auth bypass) - #14

Merged
man4ish merged 1 commit into
mainfrom
fix/jwt-secret-empty-auth-bypass
Sep 1, 2026
Merged

fix: fail startup when AUTH_ENABLED=true and JWT_SECRET is unset (closes silent auth bypass)#14
man4ish merged 1 commit into
mainfrom
fix/jwt-secret-empty-auth-bypass

Conversation

@man4ish

@man4ish man4ish commented Sep 1, 2026

Copy link
Copy Markdown
Collaborator

What

Closes the silent auth-bypass gap documented in tests/test_auth.py (PR #13): when AUTH_ENABLED=true but JWT_SECRET is unset/empty, require_auth() used to validate JWTs against an empty-string HMAC secret, which PyJWT accepts — anyone could forge jwt.encode({"sub": "attacker"}, "", algorithm="HS256") and get in.

The fix makes a misconfigured deployment refuse to start instead of coming up looking secured while actually being open.

Before / after

Before this PRAUTH_ENABLED=true, JWT_SECRET unset:

  • The app started normally, served requests, looked fully operational and "secured."
  • Any caller who forged a token with secret="" got a 200 with a real actor identity — full bypass, silently.

After this PR — same misconfiguration:

  • The app refuses to start. uvicorn exits with a non-zero code before binding a port or serving a single request.
$ AUTH_ENABLED=true JWT_SECRET= uvicorn api.main:app --host 127.0.0.1 --port 8974
INFO:     Started server process [1618811]
INFO:     Waiting for application startup.
ERROR:    Traceback (most recent call last):
  ...
  File ".../api/main.py", line 100, in startup_event
    validate_auth_config()
  File ".../api/auth.py", line 41, in validate_auth_config
    raise RuntimeError(
RuntimeError: AUTH_ENABLED is true but JWT_SECRET is not set -- refusing to start with auth silently disabled.

ERROR:    Application startup failed. Exiting.
$ echo $?
3

Correctly configuredAUTH_ENABLED=true, JWT_SECRET set — starts and works exactly as before:

$ AUTH_ENABLED=true JWT_SECRET=a-real-shared-secret uvicorn api.main:app --host 127.0.0.1 --port 8975
INFO:     Application startup complete.

$ curl -X POST localhost:8975/rag/query -d '{"query":"test"}'   # no token
{"detail":"Authorization header is missing"}   # 401

$ curl -X POST localhost:8975/rag/query -H "Authorization: Bearer <valid-jwt>" -d '{"query":"What is FAISS used for?"}'
{"query":"...", "answer":"Based on the provided context, FAISS ...", "sources":[...], "context_used":5, ...}   # 200

Implementation

  • api/auth.py: new validate_auth_config() — raises RuntimeError("AUTH_ENABLED is true but JWT_SECRET is not set -- refusing to start with auth silently disabled.") iff _auth_enabled() and not _jwt_secret(). require_auth() itself is unchanged.
  • api/main.py: startup_event() now calls validate_auth_config() before init_control_plane(), so a misconfigured deployment fails at FastAPI's ASGI startup lifecycle — before serving anything — not as a warning/log line.
  • AUTH_ENABLED=false is completely unaffected: validate_auth_config()'s check short-circuits on _auth_enabled() first, so JWT_SECRET stays fully irrelevant when auth is off. Covered by test_validate_auth_config_ignores_missing_secret_when_auth_disabled.

Test changes (tests/test_auth.py)

Per instructions, the test that previously documented the bypass (test_require_auth_unset_secret_accepts_token_forged_with_empty_secret — asserted a forged token got a 200) is replaced, not weakened, with:

  • test_validate_auth_config_raises_when_enabled_without_secret — unset JWT_SECRET
  • test_validate_auth_config_raises_when_enabled_with_blank_secret — whitespace-only JWT_SECRET (strips to "")
  • test_validate_auth_config_passes_when_enabled_with_secret_set — must not raise
  • test_validate_auth_config_ignores_missing_secret_when_auth_disabled — confirms requirement 4 (AUTH_ENABLED=false unaffected)
  • test_startup_refuses_when_auth_enabled_without_secret — end-to-end: calls the real api.main.startup_event() (not just the isolated guard function), confirms it raises

test_require_auth_unset_secret_internal_header_path_is_unreachable is kept as-is — it documents require_auth()'s own internal-header short-circuit behavior when the secret is empty, which is unrelated to this fix and still holds.

Test results

Related but separate: Dockerfile's ${JWT_SECRET:-change-me} fallback

Dockerfile:115 bakes ${JWT_SECRET:-change-me} into nginx's generated devhub.conf as the X-Devhub-Internal header value used for the UI's same-origin proxy requests. This PR does not touch it — flagging explicitly per instructions rather than silently fixing or silently ignoring it.

Analysis: in the current single-container architecture, this fix already neutralizes practical exploitability of that fallback: nginx and FastAPI share the same container env, so (a) if AUTH_ENABLED=true and JWT_SECRET is genuinely unset, FastAPI now refuses to start and nginx's proxy target never comes up (502s, not a bypass); (b) if AUTH_ENABLED=false, no auth is enforced regardless of the header, so there's nothing to bypass; (c) if JWT_SECRET is set, nginx bakes the real value, not the fallback.

Recommendation: file as its own follow-up PR, not bundled here. Reasons:

  • It's a different code path (Dockerfile/shell ${VAR:-default} fallback + nginx config generation vs. this PR's Python-level AUTH_ENABLED/JWT_SECRET validation) — deserves its own review and its own Docker build+run verification, not folded into a security-critical Python behavior-change PR.
  • It's brittle-by-coincidence rather than fixed-by-design: the reasoning above depends on today's specific single-container, shared-env, crash-fast architecture. A hardcoded, publicly-visible-in-source placeholder like "change-me" is still bad practice on its own and worth removing outright (e.g. failing the container's CMD script if JWT_SECRET is unset, mirroring this PR's spirit, rather than silently falling back).

Scope

3 files: api/auth.py, api/main.py, tests/test_auth.py.

⚠️ Do not auto-merge

This is a security-critical behavior change. Per instructions, not merged automatically — results reported above; merge only after explicit confirmation.

…ses silent auth bypass)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012RttwcxBgsnXH2TwQGQsrp
@man4ish
man4ish merged commit 7d11002 into main Sep 1, 2026
2 of 3 checks passed
@man4ish
man4ish deleted the fix/jwt-secret-empty-auth-bypass branch September 1, 2026 01:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant