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];
}