Just recently, I added passkeys support to a private admin panel which I maintain. It was an interesting experiment. The web app itself is written in flask, and some time in mid 2023 I did a little bit of research to build this support. I decided to use the py-webauthn library (developed by Duo Labs.) Duo did a nice job this library, and they have a decent amount of documentation you can reference. Still, I feel the experience could have been a little better.

I setup a virtual environment with Python 3.11, installed the latest versions of all the needed dependencies, and with very little fiddling I was able to stand-up a working proof-of-concept. So far, it was a pretty decent experience. For a few reasons, I wasn’t able to deploy this right away, but fast-forward 6 months or so and it was now time to deploy.

I built a fresh cloud VM, but when I deployed the code it broke miserably. The first issue was several errors in a couple of the panel dependencies. Some of the new versions of Python libraries were erring while being used with other (now incompatible) libraries. It’s worth noting that these library combinations worked fine with the versions released 6 months before. Unfortunately, the incompatible libraries didn’t have newer versions yet… 🤪

The quick fix here was to patch the libraries myself. This isn’t a great long-term solution, but it would do for now. However, once these were patched, I was faced with a new problem: webauthn no longer respected INTERNAL transports for auth. This means I couldn’t use things like touchID or faceID on Apple devices, or Windows Hello. I’m going to be honest, I was banging my head quite a bit on this one. Running code locally, it seemed to work. Running it remotely, everything broke. Then I made a new local virtual environment and that broke too! I ended up spending 2 frustrating days on this. Let me save you some time, though, and cut to the chase.

I eventually realized, the encoding had subtle differences between what was in the data stores and what was in the debug output. When I first wrote this integration, py-webauthn and its companion examples were using base64.b64encode() and base64.b64decode() to encode certain pieces of data in the JSON payloads. My backend code used these functions as well, to send off compatible payloads. (This makes sense.) However, the webauthn spec explicitly defines the use of base64url (this is essentially base64 with a filename-safe character set, and padding characters omitted.) Although similar, using base64 is technically against spec. In the newest version of py-webauthn and related examples, this was changed. Instead of the built-in python base64 lib, they now provide a handful of helper functions to make integrations easier. In this case, base64url_to_bytes() and bytes_to_base64url().

Now, technically there is a note in the README and online docs mentioning that the spec uses base64url and these functions are now available. But there isn’t a mention this was a change from previous behaviour, so it wasn’t immediately obvious this change was responsible for breaking some of the transports just not the whole auth process. It was further compounded by the fact that the values in the first test environment just happened to encode to the same output in base64 as base64url. This means it worked in dev, but not in prod!

Anyway, the fix was simple. Swap out base64.b64encode() with bytes_to_base64url(), and it all worked!

Overall, it wasn’t terrible, but I do wish the documentation were a bit more explicit (especially for upgrades.) webauthn is getting easier to implement, but we need to lower the bar for adoption to make this more ubiquitous.

If you are looking to integrate webauthn, Duo made a great little website to demo and explain the capabilities, complete with examples and documentation. Feel free to check it out here: https://webauthn.io/