# webauthn.io: a Django test bed for WebAuthn calls

> The webauthn.io repository is the source of a demonstration site for WebAuthn, built on Django with a Caddy reverse proxy in front of it and Redis for caching, using the Duo maintained py_webauthn library. Its most useful feature is also its most honest: the README states plainly that the site is not a best practice example and does not attempt to solve the relying party privacy problems the specification describes.

**duo-labs/webauthn.io** — The source code for webauthn.io, a demonstration of WebAuthn.

- Repository: https://github.com/duo-labs/webauthn.io
- Website: https://webauthn.io
- Stars: 746 · Forks: 138
- Language: Python
- License: BSD-3-Clause
- Published: 2026-10-21 · Updated: 2026-10-21 · Language: en
- Canonical page: https://hysenlabs.com/projects/duo-labs-webauthn-io

## A demonstration site, and the source behind it

The webauthn.io repository holds the source code for webauthn.io, a demonstration of WebAuthn, and the README describes it as Duo's introduction to the wonderful world of WebAuthn, powered by the py_webauthn library that Duo maintains.

The word introduction is the right frame for what the project is. It is a working site that exercises the WebAuthn API from the browser to a real Django backend, so that a developer can read the request and response handling in a language they already know, and so that the many possible shapes of a WebAuthn exchange can be tried in a browser rather than theorised about.

The licensing is BSD-3-Clause, the primary language is Python, the site itself is at webauthn.io, and the repository is not archived with the last push on 2026-08-06. There are no GitHub releases, so the deployment follows the repository rather than a versioned artefact.

The stack is worth listing, because each piece is doing a specific job. Django is the framework and provides the session handling, the CSRF machinery, and the admin surface. Gunicorn is the WSGI server. Redis and the hiredis client are the cache. Caddy sits in front as the reverse proxy and the TLS terminator. And the webauthn library, at version 3.0.0 in the pinned dependency list, is the actual WebAuthn implementation, alongside a pinned dilithium-py dependency whose role the README does not explain.

The dependency list is pinned exactly, with every version written out in full, which is the right choice for a security project and also a hint about how you should run it.

## The privacy caveat, which is the most useful sentence in the README

There is a section on WebAuthn privacy considerations, and it deserves to be quoted rather than paraphrased, because it is unusual for a demonstration site to say this about itself.

The site is not intended to represent WebAuthn best practices. Its use of WebAuthn is optimized to offer a test bed for the many permutations of possible WebAuthn API calls.

And the consequence is stated directly. The codebase does not meaningfully try to offer solutions to any of the relying party specific privacy considerations as detailed in the specification, like username enumeration or personal information leakage via user ID.

The closing advice is to keep that in mind as you dive deeper into the internals of the project, and to consult the specification for ideas on how to address those problems in your own relying party implementation.

That is a well judged warning, and it is easy to read past. A demo site that shows you a working registration and login flow will look like a working registration and login flow, and the two things that a relying party most often gets wrong, telling an attacker whether an account exists and leaking personal information in the user handle, are precisely the things this project does not attempt.

So the correct way to use it is as a laboratory. The permutations of API calls are the point, and the privacy properties of a real implementation are your problem to solve with the specification in hand.

## Running it locally, and the CSS wrinkle

The prerequisites are Docker and uv, and the README adds a note about the second one: run uv sync locally to install the dependencies and give the editor something to crawl.

That last clause is a small but useful detail. The site runs in containers, but a language server running on your machine needs the dependencies to resolve the imports, and without them the editor is full of errors that are not real.

Starting the development environment is a single script.

```sh
./start-dev.sh
```

The site is then available at localhost.

The CSS section is the one place where the developer workflow has a catch, and the reason is a decision about the frontend. The styles lean on the browsers and their native CSS nesting support, which allows for nicer CSS authoring without pre-processors, and that is a real simplification. The cost is that the static files have to be regenerated for the change to be served by Caddy on subsequent reloads, and the command to do that is a Django collectstatic run inside the container.

```sh
docker compose run django ./manage.py collectstatic --no-input
```

So the loop is edit the CSS, run that command, and reload. That is a small tax for not having a CSS preprocessor, and it is the kind of trade a demonstration project makes deliberately to keep the stack short.

Testing is a single script as well, and it covers everything in the homepage tests module.

```sh
./run-tests.sh
```

The Django application itself lives in the _app directory, and the static files are mounted into a volume shared with Caddy, which is how the reverse proxy serves them without Django serving them in production.

## The environment variables, and the ones that must agree

There are six environment variables, and the README describes each in one line, which is enough to see the shape of the configuration.

The Django secret key is a sufficiently random string. The production host name is the domain name the site will be hosted at. The production CSRF origin is the domain name plus protocol from which requests to the backend should occur. The relying party identifier is typically the same as the production host name. The relying party name is a representation of the site's name to be shown to users. And the expected origin is the domain name plus protocol at which WebAuthn will be invoked.

Three of those six are about the domain, and they are the ones that have to agree with each other.

WebAuthn binds a credential to a relying party identifier, and a relying party identifier has to be a domain that the user is actually on. If the host name is the example domain, the relying party identifier has to be the example domain, and the expected origin has to be the example domain with the https scheme. If any of the three is a development leftover, the browser will refuse the credential, and the failure appears as a WebAuthn error rather than as a configuration message.

That is why the README calls the CSRF origin and the expected origin separate variables rather than deriving one from the other. They are the same value in a correct deployment and different values in a common broken one, for example when the site is proxied and the backend sees a different host.

The compose file also passes two variables with the name of an app site association, an application identifier prefix and a bundle identifier, which are the pair of values an Apple platform needs to link an application to a web domain for passkeys.

## Production, and how an update reaches the running site

The production path is a second script, and the difference from development is mostly about TLS and about how the containers are wired.

```sh
./start-prod.sh
```

The site is then available for viewing at the production host name, over https, and the included Caddy server, running as the caddy service in the compose file, handles the SSL certificate management.

The compose file is where the wiring is visible. Caddy publishes ports 80, 443, and 443 over UDP, which is the HTTP/3 port, and it mounts its own configuration file from the repository along with the static files volume, the data volume for certificates, and the config volume. The Django service is built from the Dockerfile, depends on Redis, and runs gunicorn against the project's WSGI application with a configuration file. It joins both the Caddy network and the Redis network, so it can talk to the cache without exposing it, and the Caddy service only joins the Caddy network, so the cache is not reachable from the proxy.

That network separation is the part of the compose file worth noticing. The usual mistake in a two service setup is to put everything on one network, which means the reverse proxy can reach the cache and anything else that is on it.

The secret handling is also careful. The Django secret key is a compose secret sourced from an environment variable, and the Django service reads it from a file path in the secrets mount rather than from the environment directly. The Dockerfile requires the secret to be present at build time, because the collectstatic step runs during the image build and needs the key to be configured, and it mounts it as a build secret for exactly that step.

The update procedure is two commands, a git pull and an update script, and the README notes the operational detail that the Django and Caddy services are temporarily stopped during the build and restart once Django has been rebuilt.

## Conclusion

webauthn.io fits a developer who wants to see WebAuthn calls in a real Django application, who wants to try the many permutations of the API rather than read about them, and who wants the server side in a language they can read. It does not fit a team looking for a reference implementation to copy into production, and it does not fit an evaluation of WebAuthn best practices, because the site explicitly declines to model them. Verify first that the relying party identifier, the expected origin, and the host name agree with each other, since WebAuthn binds the credential to the domain, and that you read the privacy section of the specification yourself, because the site is a test bed and the trade-offs are the specification's to make.

## FAQ

### What is webauthn.io?

It is a demonstration of WebAuthn, and the repository holds the source code for it. The site is built on Django with Caddy in front and Redis for caching, and it uses the Duo maintained py_webauthn library, with the site itself at webauthn.io and the code under a BSD-3-Clause licence.

### Is webauthn.io safe to use as a reference implementation?

Not as a privacy reference. The README says the site is not intended to represent WebAuthn best practices, because its use of WebAuthn is optimized to offer a test bed for the many permutations of possible API calls, and the codebase does not meaningfully try to solve the relying party privacy considerations such as username enumeration or personal information leakage via the user ID.

### How do I run the webauthn.io site locally?

You need Docker and uv, and you run uv sync locally so that the dependencies are available to the editor. Then the start development script brings the site up at localhost. The tests are run with the test script, and they cover everything in the homepage tests module.

### Which environment variables does the webauthn.io site need?

Six: a random Django secret key, the production host name, the production CSRF origin, the relying party identifier, the relying party name shown to users, and the expected origin where WebAuthn is invoked. The host name, the relying party identifier, and the expected origin have to agree with each other, because WebAuthn binds a credential to the domain.

### How does the webauthn.io deployment handle TLS and secrets?

Caddy runs as a service in the compose file and handles the SSL certificate management, publishing ports 80, 443, and 443 over UDP. The Django secret key is a compose secret taken from an environment variable and read from a file path by the application, and the Dockerfile requires it at build time for the collectstatic step.

## Sources

- [duo-labs/webauthn.io on GitHub](https://github.com/duo-labs/webauthn.io)
- [Issues](https://github.com/duo-labs/webauthn.io/issues)
- [License: BSD-3-Clause](https://github.com/duo-labs/webauthn.io/blob/master/LICENSE)
- [Project website](https://webauthn.io)
- [README](https://github.com/duo-labs/webauthn.io/blob/master/README.md)

---

Hysen Labs editorial analysis, written from the project's own repository and release notes. Cite the canonical page: https://hysenlabs.com/projects/duo-labs-webauthn-io
