June 27, 2023, noon

Serverless WebAuthn with Astro, Cloudflare Pages, and D1 - The Good and the Bad

aostiles

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: static_vs_dynamic.png

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: d1_docs_1.png d1_docs_2.png Is data persisted by default? In practice, yes.
    The Pages docs reference adding a --d1=<BINDING_NAME> argument to the wrangler pages dev command for access to a local D1 database. I found this is not what you want. You should instead rely entirely on a wrangler.toml file and omit the --d1= argument. My run command is npm run build && wrangler pages dev ./dist. My wrangler.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. webauthn_settings.png 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.

You just read issue #1 of aostiles. You can also browse the full archives of this newsletter.

Share on Twitter
Find aostiles elsewhere: GitHub Twitter aostil.es
Brought to you by Buttondown, the easiest way to start and grow your newsletter.