Astro + Turso + Cloudflare Pages security playbook

Updated

Astro + Turso + Cloudflare is the rising edge stack for content-heavy apps. The fast cold-start story is the draw; the security story is straightforward if you keep Turso credentials server-only and audit island prop pass-throughs for accidentally-shared secrets.

What breaks on this stack

Server secret leaked to island via prop

Island components receive props at hydration time; passing TURSO_AUTH_TOKEN to a client island ships it to every visitor. Keep server-side fetch server-only; pass display-data only to islands.

Read the guide →

Turso connection string in PUBLIC_ env

PUBLIC_-prefixed env vars ship to client. TURSO_DATABASE_URL must NOT have PUBLIC_ prefix.

Read the guide →

Endpoints without auth

Astro Endpoints (/pages/api) accept any caller unless the handler checks session.

Read the guide →

BOLA on dynamic [id] routes

Dynamic routes returning by-id without ownership check — same fix as Next.js BOLA.

Read the guide →

Cloudflare Worker bindings over-broad

Bindings configured in wrangler.toml grant scope to the Worker; over-broad scope = downstream blast radius.

Read the guide →

Pre-ship checklist

  • Turso credentials server-only (no PUBLIC_ prefix)
  • Astro island props reviewed for inadvertent secret pass-through
  • Endpoints require session check
  • Dynamic-route ownership checks present
  • Cloudflare bindings scoped per route
  • wrangler.toml committed but binding-secrets not (env-driven)
  • Security headers via astro.config.mjs
  • Rate limits on paid-API proxy routes

Starter config

// astro.config.mjs - security headers + edge runtime
import { defineConfig } from "astro/config";
import cloudflare from "@astrojs/cloudflare";
export default defineConfig({
  output: "server",
  adapter: cloudflare({ mode: "directory" }),
  vite: { ssr: { noExternal: ["@libsql/client"] } },
  server: {
    headers: {
      "Strict-Transport-Security": "max-age=63072000; includeSubDomains; preload",
      "X-Frame-Options": "DENY",
      "X-Content-Type-Options": "nosniff",
      "Referrer-Policy": "strict-origin-when-cross-origin",
    },
  },
});