Serverless WebAuthn with Astro, Cloudflare Pages, and D1 - The Good and the Bad
You want a personal website. But you don't want to maintain servers or databases. You want your site to be cheap to run, fast, and not jammed with JavaScript.
At least that's what I wanted.
Static site generators are a great option but get tricky once you need a little bit of dynamic content. My site is "mostly static" but features:
- a contact form connected to a database
- a private section for my friends
- spam protection via Cloudflare Turnstile
I built it using Astro on Cloudflare Pages.
Here's the set of options for building a simple website in 2023 as I see it:
An artisanal webserver in Go is tempting, but overkill. I don't want to re-invent the wheel.
Astro is part of a new class of SSR frameworks that fill the gap between a purely static site and a custom dynamic webserver. Here are some other SSR frameworks. I like that Astro features built-in markdown support and by default ships zero JS over the wire.
Cloudflare Runtime
I chose to deploy my Astro site on Cloudflare Pages because it is cheap, fast, and includes a managed database. I want my personal website to just work so I can do other things.
The CF runtime doesn't care about Astro. It provides a runtime API for the dynamic bits of a website. The contract is: "code to our runtime API and we will automatically deploy your code." Astro provides a Cloudflare adapter to honor the contract.
Here's what went well and what didn't.
The Good
- TypeScript is pretty good. I had used it for frontend work before, but this project makes me consider it as a contender in other contexts. The VS Code integration is great. I used TypeScript's union types and particularly liked that string literals can be specified as unions. For example:
type Options = { mode: 'directory' | 'advanced'; };
However, I mostly used union types in return function signatures. For example: "this function returns string | null
". I like the explicitness of this style, but note it may not be idiomatic as TypeScript supports optional chaining.
- Cloudflare Turnstile was easy to implement. Here is the server-side code I had to write:
// Get the client-side "is or is not human" token generated by Turnstile
const turnstile_token = data.get("cf-turnstile-response");
formData.append("response", turnstile_token);
// Add in the Turnstile secret key
formData.append("secret", import.meta.env.PUBLIC_TURNSTILE_SECRET_KEY);
// Send the token and secret key to Turnstile
const url = "https://challenges.cloudflare.com/turnstile/v0/siteverify";
const result = await fetch(url, {
body: formData,
method: "POST",
});
// Find out whether the form submitter was human
const outcome = await result.json();
- Astro largely got out of my way. I had several pages represented in my
src/pages
directory:
index.astro
login.astro
friends.astro
...
and was able to share a common header in src/components/Header.astro
. The pages and header look like HTML. Here's how a simple conditional looks in an .astro
file:
<Header/>
<main>
{data.username ? (
<div>
// logged in
</div>
) : (
// not logged in
)}
</main>
- Cloudflare Tunnel works really well. I would consider using it for projects that do not otherwise use Cloudflare. I set up a named tunnel to a subdomain. Real users were able to try the WebAuthn flow on real devices on a site served by my laptop. Development cycles were fast.
- Cloudflare Pages deploys are fast and hassle-free. I had a brand-new version 47 seconds after pushing to
main
.
- D1 is just SQLite. Querying JSON works. Here's where I store authenticator credential JSON blobs:
CREATE TABLE IF NOT EXISTS creds (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER,
cred TEXT,
FOREIGN KEY(user_id) REFERENCES users(id)
);
and query by credential ID:
SELECT cred
FROM creds
WHERE json_extract(creds.cred, '$.id') = ?1;
The Bad
- D1 documentation is inconsistent:
Is data persisted by default? In practice, yes.
The Pages docs reference adding a--d1=<BINDING_NAME>
argument to thewrangler pages dev
command for access to a local D1 database. I found this is not what you want. You should instead rely entirely on awrangler.toml
file and omit the--d1=
argument. My run command isnpm run build && wrangler pages dev ./dist
. Mywrangler.toml
file looks like this:
[[d1_databases]]
binding = "SITE_DB"
database_name = "site"
database_id = "<identifier copied from Cloudflare dashboard>"
The corresponding local sqlite database can be inspected with:
sqlite3 .wrangler/state/v3/d1/<identifier from Cloudflare dashboard>/db.sqlite
- WebAuthn is hard to test locally. Neither Playwright nor Cypress officially supports testing it. Selenium seems to as does this bespoke Go library.
- WebAuthn, as a standard, has many knobs. I did not find it straightforward to implement. I can recommend the library I used whose diagrams were quite helpful. You probably don't want discoverable credentials.
- The Miniflare 3 dev server only supports HTTP.
- Logging is hard. Logs are not stored. My friend is having trouble registering on my site. I am coordinating a time with him when he can retry and I can tail the logs.
- Documentation is scattered. It was tricky to figure out the types for accessing D1 from an Astro API endpoint. Here's the relevant code from
pages/api/contact.ts
:
import type { APIRoute } from "astro";
import type { APIContext } from "astro";
import { getRuntime } from "@astrojs/cloudflare/runtime";
import type { D1Database } from "@cloudflare/workers-types"
export const post: APIRoute = async ({ request, redirect }: APIContext) => {
const runtime = getRuntime(request);
const { SITE_DB } = (runtime.env as { SITE_DB: D1Database });
...
Conclusion
I'm reasonably happy with how this project turned out. I would use some of these tools for future simple sites. For a product, I would use a third-party auth provider and need to figure out better logging, debugging, and testing.
If you'd like to set up a site using these technologies, check out my code template.