Turso Security Guide for Vibe Coders

Share

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 --permission read-only

# Create a full-access token (use sparingly)
turso db tokens create my-database

# Create a token with expiration
turso db tokens create my-database --expiration 7d

# Revoke a token
turso db tokens revoke my-database [token-name]

Token Best Practices: Use read-only tokens for analytics and reporting. Use expiring tokens for CI/CD and temporary access. Rotate production tokens regularly. Never share tokens between environments.

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

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
const client = createClient({
  url: 'file:local.db',  // Local SQLite file
  syncUrl: process.env.TURSO_DATABASE_URL,
  authToken: process.env.TURSO_AUTH_TOKEN,
  syncInterval: 60,  // Sync every 60 seconds
});

// Security considerations:
// 1. Local file permissions - ensure only your app can read it
// 2. Data at rest - local replica is unencrypted SQLite
// 3. Sync conflicts - understand how conflicts are resolved

Embedded Replica Risks: The local replica file is a standard SQLite database. It's not encrypted by default. If your server is compromised, the local file could be accessed. Consider this when storing sensitive data and implement application-level encryption for highly sensitive fields.

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)
  • Production tokens rotated regularly
  • All queries use parameterized arguments (? or )
  • No string interpolation in SQL queries
  • Dynamic column/table names validated against allowlists
  • Input validated with Zod before database operations
  • Embedded replicas secured with proper file permissions
  • 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?

Yes, Turso encrypts data in transit (TLS) and at rest on their servers. However, embedded replicas are standard SQLite files without encryption. For sensitive data in embedded replicas, implement application-level encryption.

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?

Immediately revoke the token using turso db tokens revoke and create a new one. Audit your database for any unauthorized changes. Consider implementing IP allowlisting in Turso's pro features.

Scan Your Turso Integration

Find SQL injection vulnerabilities, exposed tokens, and security issues before they reach production.

Start Free Scan
Tool & Platform Guides

Turso Security Guide for Vibe Coders