Astro + Turso security — edge-SQL architecture

Astro + Turso is an increasingly popular edge stack: Astro's server endpoints run at the CDN edge, Turso replicates SQLite globally, and the whole stack fits in a single small deployment. Security-wise it is mostly conventional — SQL injection defense, auth on routes, TLS — but Astro has some framework-specific gotchas. Astro's `src/pages/api/*` routes are unauthenticated by default. Every handler needs its own auth check or Astro middleware has to enforce it. The `PUBLIC_` env-var prefix (analogous to Next.js's `NEXT_PUBLIC_`) ships values to the client bundle; any secret with that prefix is public. Turso auth tokens have granular scoping — read-only, read-write, per-database — and the default is often broader than needed. On the libSQL side: always use `.execute()` with parameter bindings, never template strings. libSQL supports `Statement` objects that prepare the query and bind values safely. Turso's edge replicas mean a write in one region may not be visible in another for tens of milliseconds — that's a consistency quirk, not a security issue, but it can confuse authorization logic that reads immediately after writing.

What breaks on this stack

Astro server endpoints without auth

Every handler in src/pages/api/*.ts receives requests directly. Either add auth to each handler or centralize in src/middleware.ts.

libSQL injection via string interpolation

`db.execute("select * from users where id = ${id}")` is injection-prone. Use `db.execute({ sql: 'select ... where id = ?', args: [id] })`.

PUBLIC_ env vars leaking secrets

Anything prefixed PUBLIC_ is embedded in the client bundle. Review every PUBLIC_ var and move secrets to unprefixed server-only names.

Turso auth tokens over-scoped

Default auth tokens may grant read-write across all databases in your organization. Scope tokens per-database and per-operation (read-only vs read-write).

Missing security headers on edge responses

Astro doesn't add security headers by default. Add them in src/middleware.ts applied to every response.

Pre-ship checklist

  • Every server endpoint has an auth check (or is explicitly public)
  • All libSQL queries use parameter bindings, no string interpolation
  • PUBLIC_ env vars reviewed; only public-safe values kept
  • Turso auth tokens scoped to specific databases and operations
  • Turso tokens rotated on a schedule (90 days typical)
  • src/middleware.ts enforces auth + security headers globally
  • TLS required on libSQL connections (it's the default; verify)
  • CORS policy explicit — not wildcard
  • Rate limiting added at edge layer (Cloudflare, Vercel Edge)
  • Turso point-in-time backups enabled for production database

Starter config

// src/middleware.ts — auth + CSP + CVE guard
import { defineMiddleware } from "astro:middleware";

export const onRequest = defineMiddleware(async (context, next) => {
  if (context.request.headers.has("x-middleware-subrequest")) {
    return new Response("rejected", { status: 400 });
  }
  const isPublic = context.url.pathname.startsWith("/public/")
    || context.url.pathname === "/";
  if (!isPublic) {
    const session = context.cookies.get("session")?.value;
    if (!session) return context.redirect("/login");
  }
  const response = await next();
  response.headers.set("Strict-Transport-Security", "max-age=63072000; includeSubDomains");
  response.headers.set("X-Content-Type-Options", "nosniff");
  response.headers.set("X-Frame-Options", "DENY");
  return response;
});

// src/lib/db.ts — parameterized libSQL
import { createClient } from "@libsql/client";
export const db = createClient({
  url: import.meta.env.TURSO_DB_URL!,
  authToken: import.meta.env.TURSO_DB_TOKEN!, // unprefixed — server-only
});

export async function getOrder(id: string, userId: string) {
  const { rows } = await db.execute({
    sql: "select * from orders where id = ? and user_id = ?",
    args: [id, userId],
  });
  return rows[0];
}