Skip to content

fix(server): return 401 instead of 500 when an API key contains non-ASCII characters#1719

Merged
jundot merged 1 commit into
jundot:mainfrom
richgoodson:fix/1717-non-ascii-api-key
Jun 8, 2026
Merged

fix(server): return 401 instead of 500 when an API key contains non-ASCII characters#1719
jundot merged 1 commit into
jundot:mainfrom
richgoodson:fix/1717-non-ascii-api-key

Conversation

@richgoodson

@richgoodson richgoodson commented Jun 7, 2026

Copy link
Copy Markdown
Contributor

Fixes #1717.

Problem

Any request whose Bearer token or x-api-key header contains a non-ASCII character makes every authenticated endpoint return HTTP 500 with "comparing strings with non-ASCII characters is not supported". The report shows this on both /v1/models and /v1/chat/completions.

Root cause

This is not an inference or KV cache problem. /v1/models fails too, and that endpoint never touches the engine.

verify_api_key in server.py passes the client-supplied key into secrets.compare_digest(str, str) in omlx/admin/auth.py. CPython's compare_digest raises TypeError when either str argument contains non-ASCII characters, and FastAPI surfaces that as an unhandled 500.

A non-ASCII key is easy to produce by accident; pasting a key through anything that substitutes smart quotes is enough. validate_api_key also accepts non-ASCII printable characters, so a configured main key or sub key can trigger the same TypeError on every request.

Fix

Added compare_keys to omlx/admin/auth.py. It encodes both sides as UTF-8 and compares bytes, which compare_digest accepts for any content, and keeps the constant-time property. All key comparisons (verify_api_key, verify_any_api_key, and the sub-key duplicate and delete checks in admin/routes.py) now go through it. Removed the now-unused secrets import in routes.py.

Encoding uses surrogatepass because json.loads can produce lone surrogates from escape sequences, and strict UTF-8 encoding rejects those. That matters for delete_sub_key, which compares request.key without a validate_api_key gate; with strict encoding that route would have kept its 500.

Testing

  • 8 new regression tests: 7 in tests/test_api_auth.py (helper behavior for non-ASCII and lone surrogates on either side, and the server auth dependency returning 401, not TypeError, for a non-ASCII bearer) and 1 in tests/test_admin_api_key.py (delete_sub_key with a lone-surrogate key returns 404, not 500).
  • Full suite: 5175 passed, 0 failed, 40 skipped, 66 deselected.
  • Live test: started a server with --api-key, sent non-ASCII Bearer and x-api-key values to /v1/models and /v1/chat/completions. All returned 401 with the standard error body, the correct key returned 200, and the server log contained no unhandled errors.

One thing I could not reproduce is the "first request works, then everything fails" sequence in the report. The 500 is per-request and depends only on the bytes the client sends. My read is that the failing requests carried a non-ASCII key the whole time and the successful first request used a different stored credential. Either way the fix removes the 500.

Scope note

This does not make configured non-ASCII keys usable over HTTP. Starlette decodes header values as latin-1, so a client sending UTF-8 non-ASCII bytes will never match a configured non-ASCII key; it now gets a clean 401 instead of a 500. If you would rather also reject non-ASCII keys at creation time (400 going forward, migrate existing, the same shape as the hot-cache "auto" fix in #1612), I am happy to do that as a follow-up.

secrets.compare_digest raises TypeError for str arguments containing
non-ASCII characters. Any request with a non-ASCII Bearer token or
x-api-key header turned every authenticated endpoint into an unhandled
500 instead of a 401, and a configured non-ASCII key did the same.

Add compare_keys to admin/auth.py, which compares UTF-8 bytes and keeps
the constant-time guarantee, and route all key comparisons through it.
Encode with surrogatepass so lone surrogates, which json.loads can
produce and which delete_sub_key compares without a validation gate,
also return False instead of raising.

Fixes jundot#1717
@richgoodson richgoodson force-pushed the fix/1717-non-ascii-api-key branch from 19b95e8 to 7c5035b Compare June 7, 2026 15:05
@richgoodson richgoodson marked this pull request as ready for review June 7, 2026 15:12
@jundot

jundot commented Jun 8, 2026

Copy link
Copy Markdown
Owner

Thanks for the fix. I verified the failure path: the public API auth dependency and the admin key comparisons now go through compare_keys, and the remaining compare_digest usage is contained inside that helper. The surrogatepass handling also covers the JSON-route edge case without changing normal ASCII behavior. This looks good to me, and I'm going to merge it.

@jundot jundot merged commit 5a77633 into jundot:main Jun 8, 2026
4 checks passed
@richgoodson richgoodson deleted the fix/1717-non-ascii-api-key branch June 10, 2026 15:32
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.

HTTP 500: "comparing strings with non-ASCII characters is not supported" on all API endpoints after first inference

2 participants