Dr Max Grossmann

Five years of unauthenticated remote data exfiltration and destruction in oTree, the world’s most popular experimental economics framework

Posted: 2026-06-15 · Last updated: 2026-06-15 ·

For over five years, any participant who received an oTree start link (a Prolific or MTurk worker, a lab undergraduate, a colleague invited to pilot) could download the built-in CSV data exports for every session the server had ever run, and permanently delete any of those sessions. No special access was required. A start link already contains the server's URL, and the two endpoints involved — /export and /delete_sessions — performed no authentication check.

oTree uses two communication channels: ordinary HTTP requests (page loads, form submissions, admin actions) and WebSockets (real-time features, including data export and session deletion). The authentication logic existed in the code but was only wired up for HTTP requests. WebSocket connections were registered through a separate code path that skipped authentication entirely. Since /export and /delete_sessions are WebSocket endpoints, setting an admin password, setting OTREE_AUTH_LEVEL=STUDY, or running in production mode made no difference — a server with every documented security setting at its strictest value was as exposed as a default install.

On 17 April 2026, oTree 5.11.5 and 6.0.14 shipped a partial fix for a vulnerability I discovered and privately reported to oTree's maintainer on 16 April. The vulnerability has been in the framework since the first beta of oTree 5 on 21 February 2021 (with earlier alphas affected since at least December 2020). oTree publishes no SECURITY.md, no PGP key, and no secure disclosure channel of any kind.

Between that initial disclosure and this post's publication on June 15, I worked tirelessly to discreetly contact dozens of experimenters, lab managers, and research-support staff around the world responsible for live or recently used oTree deployments. oTree has no official advisory channel or user registry, and the maintainer issued no public warning, so individual outreach was the only way to reach affected operators. That outreach was necessarily incomplete — this post is intended to reach the operators it could not.

Table of contents

Mitigation

Two things have to be true, at the same time, for your server to be safe from this bug: (1) you must be running a patched version of oTree, and (2) OTREE_AUTH_LEVEL must be set to STUDY (or DEMO) in the environment that actually runs oTree. Missing either one leaves /export and /delete_sessions reachable without credentials. The variable is read directly from the process environment at startup by otree/settings.py (AUTH_LEVEL = os.environ.get('OTREE_AUTH_LEVEL')), so changes only take effect on the next restart of every web and worker process. An empty string counts as "unset" for this purpose.

While you are in the environment, also set (or verify) two adjacent variables: OTREE_ADMIN_PASSWORD (a strong random password — the default project skeleton wires this through to ADMIN_PASSWORD = environ.get('OTREE_ADMIN_PASSWORD'), and without it the admin UI lets anyone in), and OTREE_PRODUCTION=1 (otherwise DEBUG is on, which exposes tracebacks and the unauthenticated demo pages).

On Heroku

Heroku is the deployment target oTree's own documentation walks operators through, and it is also where the default configuration is most obviously unsafe — the buildpack sets nothing auth-related for you. From a shell with the Heroku CLI authenticated and your app's git remote configured, run:

heroku config:set \
    OTREE_AUTH_LEVEL=STUDY \
    OTREE_ADMIN_PASSWORD='<a long random password>' \
    OTREE_PRODUCTION=1 \
    --app YOUR-APP-NAME

heroku config:set restarts all dynos automatically, so the new environment is picked up by both the web and worker processes (oTree's default Procfile runs otree prodserver1of2 and otree prodserver2of2 respectively, and both read OTREE_AUTH_LEVEL). Confirm with:

heroku config --app YOUR-APP-NAME | grep OTREE_

Then pin the patched oTree in requirements.txt — the skeleton ships a requirements.txt with a comment saying oTree may overwrite it, which you should remove before pinning so your pin survives:

# remove the "oTree-may-overwrite-this-file" header, then:
otree==5.11.5     # or otree==6.0.14 if you are on the 6.x line
psycopg2>=2.8.4

Commit and deploy:

git add requirements.txt
git commit -m 'Pin patched oTree (WS auth fix)'
git push heroku main       # or: git push heroku master

If you run oTree from a container or a private registry rather than from a git push, rebuild the image against the pinned version and re-release it; heroku config:set alone does not upgrade the oTree package.

On anything else (systemd, Docker, a VM, a dedicated host)

Install a patched version of oTree, e.g., using pip install -U 'otree==5.11.5'.

The same two variables have to reach the oTree process. For systemd, add them to the unit's Environment= lines (or an EnvironmentFile=) and run systemctl daemon-reload && systemctl restart otree. For Docker Compose, put them under environment: for every service that runs otree prodserver* (web and worker) and docker compose up -d. For a plain shell-started server, export OTREE_AUTH_LEVEL=STUDY before the otree prodserver invocation; putting it only in an interactive shell's .bashrc is not enough if the server is started by a process manager.

Verifying

After restarting, log in to the admin interface and open /server_check. You want a green alert reading "Password protection is on. Your app's AUTH_LEVEL is STUDY"; if instead you see the red "No password protection" alert, the variable did not reach the process and the WebSocket endpoints are still open. While there, also confirm the "DEBUG mode is off" alert is green.

Finally: setting these variables fixes the specific hole described in this post, but it does not retroactively tell you whether your data was exported or your sessions were wiped during the five years the endpoints were open. oTree writes no application-level audit log for either handler, so there is nothing in oTree itself to consult — but your reverse proxy may have kept records (see Checking your logs). The only durable mitigation is what the closing paragraph of this post already says — run oTree servers only for as long as a study strictly requires, and tear them down afterwards.

Checking your logs

Self-hosted setups

Although oTree keeps no record of requests to these endpoints, your reverse proxy almost certainly does. A WebSocket connection begins as an ordinary HTTP GET request; a successful upgrade is logged by nginx with status code 101. The two paths to look for are /export and /delete_sessions.

On a typical nginx setup:

# current (uncompressed) log:
grep -E '"GET /(export|delete_sessions) .+" 101 ' /var/log/nginx/access.log

# rotated logs (often gzip-compressed):
zgrep -E '"GET /(export|delete_sessions) .+" 101 ' /var/log/nginx/access.log.*

Any match is a successful WebSocket connection to one of the two vulnerable endpoints. Compare the source IP addresses against those you recognise (your own machine, your lab's VPN, etc.). A hit from an unfamiliar address is evidence that the endpoint was reached — and since neither endpoint required authentication, reaching it was sufficient to use it. Treat the server as compromised.

Heroku

On Heroku, the platform router logs every request but retains only a short rolling buffer (roughly 1,500 lines) unless you have configured a log drain. If you have a drain, search it the same way; otherwise the window has almost certainly closed for older requests. To search what Heroku still has:

heroku logs --num 9999999 --app YOUR-APP-NAME \
    | grep -E 'path="/(export|delete_sessions)"'

If either command produces output, treat the server as compromised: assume the exportable study data was read, and assess whether any sessions were deleted that you did not delete yourself.


Introduction

oTree is the dominant framework for online economic experiments. Non-free but source-available, it is used by hundreds of research groups. The accompanying paper has been cited thousands of times. I have extensively researched with and lectured on oTree. It runs paid studies on Prolific and MTurk. It stores participants' decisions, identifiers, and, depending on the experimenter, payoff information and free-text responses that are routinely personally identifiable.

Dissertations, tenure cases, replication packages, and grant-funded studies depend on data exports that for five years could be downloaded by anyone with curl, and on sessions that could be deleted by anyone willing to type a second command. The framework's selling point is that the researcher does not have to think about web infrastructure. That selling point requires that the framework actually handles web infrastructure correctly.

A note on sources before going further. oTree does have public GitHub repositories, issues, and a pull-request tab, but they do not provide a current public history of the 5.x/6.x code shipped on PyPI. For the versions discussed here, the source of truth is the PyPI source distribution that the author published under each version number.

The "version history" cited throughout this post comes from a Git repository that I had to build by hand: I downloaded each oTree release from PyPI, unpacked it, and committed the unpacked tree under a tag matching the release version, because I could not find an up-to-date upstream public history for the code actually shipped in these releases. When I refer below to particular versions of oTree, I am referring to the snapshot of the source tree that the author chose to publish to PyPI under that version number. That opacity is a significant factor in why this bug survived as long as it did.

I will at some point have to blog on how PyPI and similar software repositories simply should not allow packages that are not pulled and reproducibly built from publicly available sources. Relatedly, code that is not under institutionally approved FLOSS licenses ought not to be allowed on these repositories. Much more cleanliness is urgently needed. However, that is a matter for another day.

The bug

In plain terms: oTree's codebase contains a working authentication check for WebSocket connections — if an internal flag is set, the server verifies the user is logged in and rejects them otherwise. The problem is that the flag is never set. Here is the mechanism in detail.

oTree's HTTP views inherit a per-class attribute, _requires_login, which is set during URL registration in otree/urls.py:

def url_patterns_from_builtin_module(module_name: str):
    all_views = view_classes_from_module(module_name)
    view_urls = []
    for ViewCls in all_views:
        url_name = getattr(ViewCls, 'url_name', ViewCls.__name__)

        ViewCls._requires_login = {
            'STUDY': url_name not in ALWAYS_UNRESTRICTED,
            'DEMO':  url_name not in UNRESTRICTED_IN_DEMO_MODE,
            '':   False,
            None: False,
        }[settings.AUTH_LEVEL]
        ...

The base WebSocket consumer, _OTreeAsyncJsonWebsocketConsumer, also has a _requires_login attribute, hard-coded to False at the class level (otree/channels/consumers.py):

class _OTreeAsyncJsonWebsocketConsumer(WebSocketEndpoint):
    ...
    _requires_login = False
    ...
    async def on_connect(self, websocket):
        ...
        if (
            self._requires_login
            and not websocket.session.get(AUTH_COOKIE_NAME) == AUTH_COOKIE_VALUE
        ):
            ...
            await websocket.close(code=1008)
            return

The check is in place. The flag exists. The plumbing for switching it to True based on AUTH_LEVEL exists in the same file, for HTTP views. In releases before 5.11.5/6.0.14, it was simply never wired up for WebSockets. WebSocket routes are imported as a list and appended to the URL table directly:

routes += websocket_routes

That is the entire registration. In affected releases, nothing iterates over websocket_routes to set _requires_login. The class default — False — therefore wins for every single built-in WebSocket consumer, irrespective of AUTH_LEVEL=STUDY and irrespective of an admin password being set. The auth check in consumers.py is therefore unreachable by construction.

Two of the WebSocket consumers that this dead check fails to protect are particularly load-bearing:

  • WSDeleteSessions at /delete_sessions. Takes a list of session codes and deletes the corresponding rows. There is no other logic gating the deletion.
  • WSExportData at /export. Returns the framework's "wide" CSV: one row per participant, including participant/session fields, configured participant/session vars, and per-app/per-round player/group/subsession fields across all sessions. Session codes are among the columns.

The two endpoints compose: the output of one is shaped like the input of the other, and neither requires authentication. An attacker who has reached the export handler has, by construction, everything they need to reach the delete handler — and neither handler emits an application-level audit log entry.

The arbitrary-function-call escalation

In October 2025, in oTree 6.0.0b19, the export endpoint gained a new feature: a client can specify a function_name field, which is then passed into custom_export_app(). That function uses the name to look up and call the corresponding function in the experiment's code:

export_func = getattr(models_module, function_name, None)
...
rows = export_func(qs)

Before the fix, there was no validation that function_name referred to anything resembling a "custom export". Any function reachable as an attribute on the app's models module — the Python file where experimenters define their data structures and logic — would be looked up and called. Successful exploitation still depended on choosing a function with a compatible signature and return shape, and the names an attacker could target are easy to enumerate from a fresh oTree project. The practical impact of this part of the vulnerability is limited, as few experiments define interesting callable attributes. This bug pales in comparison to the ability of unauthenticated attackers to download and irrevocably delete session data.

The history

The chronology is worth setting out in full. All version numbers below are PyPI release tags; all dates are the upload dates of those releases.

  • 19 December 2020 (5.0.0a9): The earliest 5.x (oTree Lite) release on PyPI already ships the broken-by-default pattern. The _requires_login machinery — the HTTP-view setter in otree/urls.py, and the WebSocket consumer base class with _requires_login = False — is already present. WSDeleteSessions and WSExportData already exist as unauthenticated consumers. An ALWAYS_UNRESTRICTED set listing class names that should remain open — WSChat, WSGroupWaitPage and so on — is already in place, which strongly implies the author believed that WebSocket auth was being assigned class-by-class. It was not. No WebSocket setter is added then, or in any subsequent release for over five years.
  • 21 February 2021 (5.0.0b1): The first public beta of oTree Lite. WSDeleteSessions and WSExportData now appear in essentially their present form. They are not in ALWAYS_UNRESTRICTED. The author evidently intends them to require login. They do not. These two handlers remained largely unchanged through subsequent 5.x releases. The first stable oTree Lite release, 5.0.0, follows on 28 February 2021 with the same vulnerable code.
  • 17 October 2025 (6.0.0b17): The author adds a print(url_name, ViewCls._requires_login) statement to url_patterns_from_builtin_module and ships it to PyPI. The function being debugged handles HTTP routes; the WebSocket routes, registered just below in the same file, have no equivalent authentication logic.
  • 18 October 2025 (6.0.0b19): The function_name parameter is added to WSExportData and forwarded into custom_export_app() with no validation. The unauthenticated endpoint can now also be used to call functions in the experimenter's own code.
  • 16 April 2026: I privately reported the vulnerability to the author. oTree offers no secure disclosure channel — no SECURITY.md, no PGP key, no dedicated security address. When I asked the maintainer for a secure channel through which to send the report, he rebuffed me; the report was therefore sent by ordinary unencrypted email.
  • 17 April 2026 (5.11.5, 6.0.14): A small function, _assign_websocket_requires_login(), is finally added — over five years after the export and delete handlers first shipped — and wired into get_urlpatterns(). This websocket fix adds thirteen lines in each branch. The 6.0.14 patch additionally restricts the function_name parameter to names starting with 'custom_export'; 5.11.5 does not need this second fix because the function_name feature was never part of the 5.x line. I have not found a public advisory or release-note warning, although the framework's maintainer has acknowledged the vulnerability in private communications. Without a public advisory, existing users who upgrade will not know the release is security-critical, and those who do not upgrade will remain unaware of the exposure.

As of this writing, I have not found a public disclosure or warning to the operators whose subjects' data was exposed. Users deserve, at minimum, a clear security notice so they can assess their own exposure.

Why the fix is only partial

The 6.0.14 patch wires the WebSocket routes into the same dictionary lookup that HTTP views use:

cls._requires_login = {
    'STUDY': name not in ALWAYS_UNRESTRICTED,
    'DEMO':  name not in UNRESTRICTED_IN_DEMO_MODE,
    '':   False,
    None: False,
}[settings.AUTH_LEVEL]

Note the bottom two rows. AUTH_LEVEL is read from the environment variable OTREE_AUTH_LEVEL. If that variable is unset — as it is in every fresh install, on every developer machine, on every demo server, on every workshop laptop, on every Heroku deploy whose operator did not separately run heroku config:set OTREE_AUTH_LEVEL=STUDY (the framework sets nothing automatically on Heroku; OTREE_AUTH_LEVEL is an ordinary env var that the operator must configure themselves), and on every server whose operator did not read the small print of the deployment guide — then AUTH_LEVEL is None, and every WebSocket consumer is still wide open. The patch protects experimenters who have explicitly set OTREE_AUTH_LEVEL=STUDY in production. It protects no one else.

The /export and /delete_sessions endpoints on a default deployment are exactly as open as they were in the first public beta, 5.0.0b1, with the small consolation that the arbitrary-function escalation has been narrowed to attributes whose names happen to start with custom_export. The fix leaves the default deployment in the same state and adds a helper that only activates for operators who have already opted in to STUDY.

The documentation actively steers operators into the unprotected configuration. The Heroku setup page does not mention OTREE_AUTH_LEVEL at all; it covers dynos, Postgres and Redis, and stops there. The admin page introduces the variable as an optional feature toggle — "If you are launching an experiment and want visitors to only be able to play your app if you provided them with a start link, set the environment variable OTREE_AUTH_LEVEL to STUDY" — framed as gating participants to start links, not as the knob that decides whether strangers on the internet can download the database.

I have not found the default-unset behaviour documented anywhere, and I have not found documentation connecting AUTH_LEVEL to the /export or /delete_sessions WebSocket endpoints. An operator who sets OTREE_ADMIN_PASSWORD, sees the admin UI prompt for it, and concludes their server is protected is reacting in a way the documentation does little to correct.

Anyone who runs oTree should set OTREE_AUTH_LEVEL=STUDY and upgrade to versions 5.11.5 or 6.0.14 immediately. Anyone who does not leaves these built-in WebSocket endpoints exposed on a default deployment — the framework's threat model continues to hold that production-grade authentication is the experimenter's responsibility to enable.

Note that oTree 6 adds a "Powered by oTree" notice shown to participants, which may introduce extraneous stimuli that experimenters wish to avoid on methodological grounds. oTree 5 has also been patched, so 5.11.5 is a viable alternative.

I recommend using my modern oTree project skeleton which uses uv.

Broader context

oTree is maintained by one person, according to its website. The public GitHub repositories contain issue trackers, but I could not find a current version-control history for the 5.x/6.x code shipped on PyPI, a SECURITY.md, a security-advisory trail, or any secure method for reaching the maintainer to report a vulnerability. There is no published PGP key, no dedicated security contact, and no bug-bounty or coordinated-disclosure programme. When I reported this vulnerability on 16 April 2026, I first asked the maintainer for a secure channel; he rebuffed the request. The report was ultimately sent by ordinary unencrypted email — a medium that offers no confidentiality for vulnerability details in transit.

The bug itself is not exotic. The auth check exists in the consumer base class; the flag it tests is set for HTTP views in a function that lives in the same file as the WebSocket route registration. A review of that file would have surfaced the asymmetry. Single-maintainer projects carry inherent review limitations, and a public version-control history would lower the barrier for outside contributors to catch issues like this one.

The access-control allowlist is maintained as a hand-edited Python set of string class names — ALWAYS_UNRESTRICTED = {'WSChat', 'WSGroupWaitPage', ...} — with no enforcement that the strings refer to classes that exist, and no test exercising authentication across both transports. The fix preserves this design, so future access-control correctness still depends on these string lists staying in sync with the actual route classes.

If you are running an oTree server on the public internet today, set OTREE_AUTH_LEVEL=STUDY, set a strong admin password, upgrade to 5.11.5 or 6.0.14, and verify the deployment's access control independently rather than assuming the defaults are safe. Run oTree servers only for as long as a study strictly requires, and tear them down afterwards.