TL;DR
Turso is an edge-hosted SQLite database using libSQL. Store database tokens securely in environment variables (never commit them). Use parameterized queries to prevent SQL injection. Create separate tokens with different permissions for different environments. When using embedded replicas, understand the sync security model. The edge distribution means your security practices must be consistent across all regions.
Why Turso Security Matters for Vibe Coding
Turso provides globally distributed SQLite databases at the edge. It's popular for its simplicity and low latency. When AI tools generate Turso code, they often create working queries but may miss security fundamentals like parameterization or proper token scoping.
Since Turso uses SQLite syntax, many developers assume it's "just a simple database" and skip security best practices. But Turso databases are accessible over the network, making security critical.
Token Management
Turso uses authentication tokens instead of username/password. Manage these carefully.
Environment Variables
# .env.local (never commit)
TURSO_DATABASE_URL="libsql://your-db-name-org.turso.io"
TURSO_AUTH_TOKEN="eyJhbGciOiJFZERTQSIsInR5cCI6IkpXVCJ9..."
# For different environments
TURSO_DATABASE_URL_PROD="libsql://prod-db.turso.io"
TURSO_AUTH_TOKEN_PROD="eyJ..."
TURSO_DATABASE_URL_DEV="libsql://dev-db.turso.io"
TURSO_AUTH_TOKEN_DEV="eyJ..."
Token Scoping
Create tokens with minimal permissions:
# Create a read-only token
turso db tokens create my-database --read-only
# Create a full-access token (use sparingly)
turso db tokens create my-database
# Create a token that expires in 7 days
turso db tokens create my-database --expiration 7d
# Kill EVERY token for this database (there is no single-token revoke)
turso db tokens invalidate my-database
If an AI tool wrote your setup script, check the read-only line. Older snippets (including an earlier version of this page) use --permission read-only, which isn't a flag Turso's CLI documents. The documented flags on turso db tokens create are --read-only (-r) and --expiration (-e), and that's it.
Rotation Is All-or-Nothing
This is the part that surprises people. Turso has no command to revoke one database token. turso db tokens invalidate invalidates all tokens for the database, and Turso's docs warn it also invalidates tokens in the database's group. Your web app, your cron job and your CI pipeline all go dark at once.
So rotate in this order:
- Write down every place a token for this database lives (hosting env vars, CI secrets, local
.envfiles, preview deployments). - Run
turso db tokens invalidate my-database. - Mint new tokens and update every one of those places, then redeploy.
Expect a short outage between steps 2 and 3. If a leaked token forces your hand, take the outage: a working-but-compromised database is worse.
Group Tokens Reach Every Database
turso group tokens create my-group makes a token that, per the docs, "can be used to connect to any database in the group". It's convenient when you run one database per tenant. It also means a single leaked env var opens every tenant at once. Prefer per-database tokens unless you genuinely need cross-database access.
Token Best Practices: Use read-only tokens for analytics and reporting. Use expiring tokens for CI/CD and temporary access. Keep production and development in separate databases so invalidating one environment's tokens doesn't take down the other.
Connection Security
import { createClient } from '@libsql/client';
// Secure connection setup
const client = createClient({
url: process.env.TURSO_DATABASE_URL!,
authToken: process.env.TURSO_AUTH_TOKEN!,
});
// Verify connection string exists
if (!process.env.TURSO_DATABASE_URL || !process.env.TURSO_AUTH_TOKEN) {
throw new Error('Database configuration missing');
}
export { client };
Lock the Database to Known IPs
A token is a bearer credential: anyone holding it can connect from anywhere. Turso's allow rules add a second check. Once you set an IP list, only connections from those addresses (or CIDR ranges) are accepted:
# Only accept connections from your server's egress IP
turso db config allow-rules set my-database --ip 203.0.113.7
# See what's configured (empty means any source is accepted)
turso db config allow-rules show my-database
# Remove the rules
turso db config allow-rules clear my-database
This works best when your app runs on servers with a fixed outbound IP. Serverless and edge platforms usually don't have one, so check your host before you lock yourself out. Turso also accepts AWS VPC endpoint IDs (--aws-vpc vpce-...) if your app runs inside AWS.
SQL Injection Prevention
Turso/libSQL supports parameterized queries. Always use them.
Safe Query Patterns
// SAFE: Parameterized query with positional arguments
const user = await client.execute({
sql: 'SELECT * FROM users WHERE id = ?',
args: [userId],
});
// SAFE: Named parameters
const user = await client.execute({
sql: 'SELECT * FROM users WHERE email = :email',
args: { email: userEmail },
});
// SAFE: Multiple parameters
const posts = await client.execute({
sql: 'SELECT * FROM posts WHERE author_id = ? AND status = ? LIMIT ?',
args: [authorId, 'published', limit],
});
// SAFE: INSERT with parameters
await client.execute({
sql: 'INSERT INTO users (email, name, created_at) VALUES (?, ?, ?)',
args: [email, name, Date.now()],
});
// SAFE: UPDATE with parameters
await client.execute({
sql: 'UPDATE users SET name = ?, updated_at = ? WHERE id = ?',
args: [newName, Date.now(), userId],
});
Dangerous - String Interpolation: Never use template literals or string concatenation with user input in SQL queries. The following patterns are vulnerable to SQL injection:
// DANGEROUS: SQL injection vulnerability
const user = await client.execute(
`SELECT * FROM users WHERE email = '${email}'`
);
// DANGEROUS: Template literals without parameters
const posts = await client.execute(
`SELECT * FROM posts WHERE title LIKE '%${searchTerm}%'`
);
// DANGEROUS: Dynamic table names from user input
const data = await client.execute(
`SELECT * FROM ${tableName} WHERE id = ?`,
[id]
);
Dynamic Queries Safely
// SAFE: Allowlist for dynamic columns
const ALLOWED_SORT_COLUMNS = ['name', 'email', 'created_at'] as const;
function getSortColumn(input: string): string {
if (ALLOWED_SORT_COLUMNS.includes(input as any)) {
return input;
}
return 'created_at'; // Default fallback
}
const sortColumn = getSortColumn(userInput);
const users = await client.execute({
sql: `SELECT * FROM users ORDER BY ${sortColumn} DESC LIMIT ?`,
args: [limit],
});
// SAFE: Dynamic WHERE conditions
function buildUserQuery(filters: { name?: string; email?: string }) {
const conditions: string[] = [];
const args: any[] = [];
if (filters.name) {
conditions.push('name LIKE ?');
args.push(`%${filters.name}%`);
}
if (filters.email) {
conditions.push('email = ?');
args.push(filters.email);
}
const whereClause = conditions.length > 0
? `WHERE ${conditions.join(' AND ')}`
: '';
return client.execute({
sql: `SELECT * FROM users ${whereClause}`,
args,
});
}
Embedded Replicas Security
Turso supports embedded replicas that sync with the remote database. Understand the security model.
import { createClient } from '@libsql/client';
// Embedded replica with sync and encryption at rest
const client = createClient({
url: 'file:local.db', // Local SQLite file
syncUrl: process.env.TURSO_DATABASE_URL,
authToken: process.env.TURSO_AUTH_TOKEN,
encryptionKey: process.env.TURSO_REPLICA_KEY, // You generate and store this
syncInterval: 60, // Sync every 60 seconds
});
// Security considerations:
// 1. Without encryptionKey, local.db is a plain SQLite file anyone on the box can read
// 2. Local file permissions - ensure only your app's user can read it
// 3. Writes are visible to the writing replica at once; other replicas see them after their next sync
Embedded Replica Risks: Leave out encryptionKey and the local replica is a plain SQLite file. Anyone who can read your server's disk (a leaked backup, a shared container volume, a compromised dependency) gets a full copy of the data without ever touching your Turso token. Turso's docs say the key "should be generated and managed by you", so store it like any other secret, not next to the file.
Input Validation
Validate all input before using it in queries:
import { z } from 'zod';
// Define schemas
const CreateUserSchema = z.object({
email: z.string().email().max(255),
name: z.string().min(1).max(100),
});
const SearchSchema = z.object({
query: z.string().max(100),
page: z.coerce.number().int().positive().default(1),
limit: z.coerce.number().int().min(1).max(100).default(20),
});
// In your API handler
export async function createUser(input: unknown) {
const result = CreateUserSchema.safeParse(input);
if (!result.success) {
throw new Error('Invalid input');
}
const { email, name } = result.data;
// Now safe to use in query
await client.execute({
sql: 'INSERT INTO users (email, name) VALUES (?, ?)',
args: [email, name],
});
}
Batch Operations Security
// Safe batch operations
await client.batch([
{
sql: 'INSERT INTO users (email, name) VALUES (?, ?)',
args: [email1, name1],
},
{
sql: 'INSERT INTO users (email, name) VALUES (?, ?)',
args: [email2, name2],
},
]);
// Transaction with multiple statements
await client.transaction(async (tx) => {
// Check ownership before delete
const post = await tx.execute({
sql: 'SELECT author_id FROM posts WHERE id = ?',
args: [postId],
});
if (post.rows[0]?.author_id !== currentUserId) {
throw new Error('Not authorized');
}
await tx.execute({
sql: 'DELETE FROM posts WHERE id = ?',
args: [postId],
});
await tx.execute({
sql: 'DELETE FROM comments WHERE post_id = ?',
args: [postId],
});
});
Turso Security Checklist
- Database URL and token stored in environment variables
- Tokens scoped with minimal permissions (read-only where possible)
- You know every place a token lives, because rotation invalidates all of them at once
- Embedded replicas created with an
encryptionKey - Allow rules set if your app has a fixed outbound IP
- All queries use parameterized arguments (? or
:name) - No string interpolation in SQL queries
- Dynamic column/table names validated against allowlists
- Input validated with Zod before database operations
- Embedded replica files readable only by your app's user
- Sensitive data encrypted at application level if needed
- Different tokens used for different environments
Using with Drizzle ORM
import { drizzle } from 'drizzle-orm/libsql';
import { createClient } from '@libsql/client';
const client = createClient({
url: process.env.TURSO_DATABASE_URL!,
authToken: process.env.TURSO_AUTH_TOKEN!,
});
export const db = drizzle(client);
// Drizzle queries are automatically parameterized
const users = await db.select().from(usersTable).where(eq(usersTable.id, id));
// Safe even with dynamic conditions
const results = await db
.select()
.from(usersTable)
.where(like(usersTable.name, `%${searchTerm}%`))
.limit(20);
Is my data encrypted in Turso?
Turso encrypts data in transit and at rest on its servers. An embedded replica is a local SQLite file, and it's only encrypted if you pass an encryptionKey when you create the client. The key is yours to generate and manage.
Can I use read-only tokens for my application?
If your application only needs to read data (like a static site generator or analytics dashboard), absolutely use read-only tokens. This limits damage if the token is compromised.
How do I handle migrations securely?
Use a separate token with full permissions for migrations, ideally with a short expiration. Run migrations in CI/CD where the token is stored securely, not on developer machines.
What happens if my token is leaked?
Run turso db tokens invalidate on the database. There is no command to revoke one token: invalidate kills every token for that database and for its group, so mint fresh tokens for every service before you run it, then audit the data for unauthorized changes. Adding allow rules (turso db config allow-rules set) limits what a future leak can do.
Scan Your Turso Integration
Find SQL injection vulnerabilities, exposed tokens, and security issues before they reach production.