Security Boundaries & Access Controls in Spatial SQLite

Without deliberate access controls, any process that can read the file path of your .gpkg or .sqlite database can extract every geometry, CRS definition,…

Without deliberate access controls, any process that can read the file path of your .gpkg or .sqlite database can extract every geometry, CRS definition, and attribute — no password prompt required. This page is part of the Core Architecture & Format Standards for Spatial SQLite guide, which covers the complete storage, extension, and application stack.

GeoPackage and SpatiaLite power modern field GIS, offline-first mobile applications, and automated spatial data pipelines. Unlike enterprise relational databases, SQLite implements no native user authentication, role-based access control, or row-level security. Security boundaries must be layered: file-system permissions restrict who can open the file; an encryption extension protects data at rest if the device is lost; application-layer connection modes and metadata validation stop silent corruption before it reaches production. Skipping any layer leaves a gap that the others cannot fill.

Prerequisites Checklist

Concept & Specification Reference

Why SQLite Has No Native Access Control

SQLite’s design contract is “serverless and embeddable”: the engine has no daemon process, no TCP listener, and no login subsystem. Every caller that can open the file path gets whatever read/write capability the operating system grants that file descriptor. This is documented in the SQLite “Appropriate Uses” guidance.

For spatial databases this has concrete consequences. The gpkg_spatial_ref_sys table, the extension manifest in gpkg_extensions, and every geometry BLOB in your feature tables are equally reachable to a background sync process, a compromised third-party plugin, or a developer running an ad-hoc sqlite3 CLI session — unless the surrounding layers prevent it.

The Three Security Layers

LayerMechanismScopeFailure if skipped
File & OSPOSIX ACLs, immutable mounts, directory permissionsWho can open the file descriptorAny authenticated OS user can read/write raw bytes
EncryptionSQLCipher AES-256-CBC, SQLite Encryption Extension (SEE)Data at rest on disk or removable mediaPhysical or filesystem access exposes all geometry and metadata
Application & ConnectionRead-only URI mode, BEGIN IMMEDIATE, metadata validationScope and integrity of each Python sessionWrite bugs, partial transactions, and CRS drift corrupt spatial indexes
Defense-in-depth for Spatial SQLiteThree concentric rings protect spatial data. The outer ring is File and OS permissions (chmod, ACLs, read-only mounts). The middle ring is the Encryption layer (SQLCipher AES-256). The inner ring is Application and Connection controls (read-only URI, metadata validation, explicit transactions). At the centre sits the spatial data: GeoPackage or SpatiaLite geometry blobs and metadata tables.File & OS Layerchmod · ACLs · read-only mounts · directory permissionsEncryption LayerSQLCipher AES-256-CBC · full-file encryption at restApplication & Connectionread-only URI · metadata validation · explicit transactionsSpatial data.gpkg · .sqlite
Each ring must hold independently. Restricting Python-level connections alone leaves the file readable by any OS process. Encryption without OS controls leaves key material exposed. All three layers are required.

Relevant Specification Anchors

  • GeoPackage 1.3 (OGC 12-128r18): Requires gpkg_spatial_ref_sys, gpkg_contents, and gpkg_geometry_columns to be present before any feature table is considered valid. Any process that writes without populating these tables produces a non-conformant file.
  • SQLite URI Filenames: The mode=ro and mode=rwc parameters are documented in SQLite URI Filenames and are the authoritative way to enforce connection-level read isolation.
  • SQLCipher 4.x: Uses AES-256-CBC with PBKDF2-HMAC-SHA512 (256 000 iterations by default). Cipher page size must match the underlying SQLite page size; mismatches silently produce unreadable databases.

Step-by-Step Implementation

Step 1 — Verify and Set File-System Permissions

The OS layer is the outermost control. SQLite respects the file descriptor flags the OS returns, so a file opened with O_RDONLY cannot be written regardless of what the Python layer attempts.

python
# Python 3.9+ — OS permission verification before opening a spatial database
import os
import stat

def assert_db_permissions(db_path: str, writable: bool = False) -> None:
    """Raise PermissionError if the process cannot perform the requested access.

    os.access() checks effective uid/gid and honours ACLs and read-only
    mounts — it is more reliable than inspecting raw permission bits.
    """
    if not os.path.exists(db_path):
        raise FileNotFoundError(f"Database not found: {db_path}")
    if not os.access(db_path, os.R_OK):
        raise PermissionError(f"No read permission on {db_path}")
    if writable and not os.access(db_path, os.W_OK):
        raise PermissionError(f"No write permission on {db_path}; "
                              "check mount flags or ACLs")

    # Warn when world-readable: geometry and attribute data are exposed to all users
    mode = stat.S_IMODE(os.stat(db_path).st_mode)
    if mode & stat.S_IROTH:
        import warnings
        warnings.warn(
            f"{db_path} is world-readable (mode {oct(mode)}). "
            "Consider chmod o-r for sensitive spatial data.",
            stacklevel=2,
        )

For Linux field deployments, mount the data partition read-only when write access is not required:

bash
# Mount /dev/sdb1 as read-only; SQLite will honour the OS flag
mount -o ro /dev/sdb1 /mnt/field-data

On Windows, use icacls to strip WRITE and MODIFY from the service account running the GIS application:

bash
icacls "C:\GISData\survey.gpkg" /deny "GISService:(W,M)"

Step 2 — Open Connections with Explicit Mode URIs

SQLite URI filenames are the standard mechanism for enforcing read isolation at the connection level. Pass mode=ro to prevent the engine from acquiring a write lock, even if the underlying file is writable.

python
# Python 3.9+ — read-only and read-write connection factory for GeoPackage / SpatiaLite
import sqlite3
import logging
from contextlib import contextmanager

logger = logging.getLogger(__name__)

@contextmanager
def spatial_db_connection(db_path: str, read_only: bool = True):
    """Open a GeoPackage or SpatiaLite database with an explicit access mode.

    Yields a sqlite3.Connection; commits on clean exit, rolls back on error.
    WAL mode is only activated on writable connections — switching journal mode
    requires writing the database header, which mode=ro forbids.
    """
    mode = "ro" if read_only else "rwc"
    uri = f"file:{db_path}?mode={mode}"
    conn = None
    try:
        conn = sqlite3.connect(uri, uri=True)
        conn.row_factory = sqlite3.Row
        # Foreign-key enforcement is off by default; enable before any DML
        conn.execute("PRAGMA foreign_keys = ON;")
        if not read_only:
            # WAL mode improves concurrent reader throughput; must be set by a writer
            conn.execute("PRAGMA journal_mode = WAL;")
            conn.execute("PRAGMA synchronous = NORMAL;")
        yield conn
        if not read_only:
            conn.commit()
    except sqlite3.OperationalError:
        logger.exception("SQLite operation failed on %s", db_path)
        if conn:
            conn.rollback()
        raise
    finally:
        if conn:
            conn.close()

Step 3 — Apply Encryption with SQLCipher

When databases travel on removable media, are distributed through cloud sync, or reside on field devices that could be lost or stolen, full-file encryption is mandatory. Securing GeoPackage Files for Field Use covers key rotation and offline key distribution patterns in depth; this section focuses on the connection setup.

python
# Python 3.9+ + pysqlcipher3 — open an AES-256-encrypted GeoPackage
from pysqlcipher3 import dbapi2 as sqlcipher
import os

def open_encrypted_gpkg(db_path: str) -> sqlcipher.Connection:
    """Open a SQLCipher-encrypted GeoPackage using a key from the environment.

    Never embed the passphrase in source control. Use an environment variable,
    platform keychain, or HSM-backed secret store.
    """
    key = os.environ.get("GPKG_KEY")
    if not key:
        raise RuntimeError("GPKG_KEY environment variable is not set")

    conn = sqlcipher.connect(db_path)
    # Cipher parameters must be set before the first table access
    conn.execute("PRAGMA cipher_compatibility = 4;")
    conn.execute("PRAGMA cipher_page_size = 4096;")
    # PRAGMA cannot use bound parameters; escape single quotes to prevent injection
    safe_key = key.replace("'", "''")
    conn.execute(f"PRAGMA key = '{safe_key}';")

    # Probe sqlite_master: if decryption fails this raises OperationalError
    conn.execute("SELECT count(*) FROM sqlite_master;")
    return conn

To encrypt an existing unencrypted .gpkg without losing spatial metadata, use the ATTACH / sqlcipher_export pattern rather than re-inserting rows:

python
# Python 3.9+ + pysqlcipher3 — encrypt an existing GeoPackage in place
import os
from pysqlcipher3 import dbapi2 as sqlcipher

def encrypt_existing_gpkg(src_path: str, dst_path: str) -> None:
    """Copy src_path into a new AES-256-encrypted database at dst_path."""
    key = os.environ["GPKG_KEY"].replace("'", "''")

    src = sqlcipher.connect(src_path)
    src.execute("PRAGMA key = '';")  # open source without encryption

    src.execute(f"ATTACH DATABASE '{dst_path}' AS encrypted KEY '{key}';")
    src.execute("SELECT sqlcipher_export('encrypted');")
    src.execute("DETACH DATABASE encrypted;")
    src.close()

Step 4 — Validate Spatial Metadata Before Writes

Before any write session, confirm that the database’s spatial metadata tables are present and internally consistent. A GeoPackage missing gpkg_geometry_columns rows, or a SpatiaLite database whose geometry_columns table disagrees with the actual CRS, will produce silent geometry misregistration downstream.

The GeoPackage Specification Deep Dive documents the exact table schemas and mandatory constraints. The SpatiaLite Metadata Tables Explained page covers the parallel schema for SpatiaLite — geometry_columns, spatial_ref_sys, and views_geometry_columns.

python
# Python 3.9+ — validate spatial metadata before allowing write access
import sqlite3

def validate_spatial_metadata(
    conn: sqlite3.Connection,
    expected_srid: int = 4326,
) -> None:
    """Raise ValueError if the database lacks conformant spatial metadata.

    Handles both GeoPackage (gpkg_geometry_columns.srs_id) and SpatiaLite
    (geometry_columns.srid), which use different table and column names.
    """
    is_gpkg = conn.execute(
        "SELECT 1 FROM sqlite_master "
        "WHERE type = 'table' AND name = 'gpkg_geometry_columns';"
    ).fetchone()

    if is_gpkg:
        count = conn.execute(
            "SELECT count(*) FROM gpkg_geometry_columns WHERE srs_id = ?;",
            (expected_srid,),
        ).fetchone()[0]
        table_label = "gpkg_geometry_columns"
        col_label = "srs_id"
    else:
        count = conn.execute(
            "SELECT count(*) FROM geometry_columns WHERE srid = ?;",
            (expected_srid,),
        ).fetchone()[0]
        table_label = "geometry_columns"
        col_label = "srid"

    if count == 0:
        raise ValueError(
            f"No geometry tables registered with {col_label}={expected_srid} "
            f"in {table_label}. Verify CRS registration before writing."
        )

Step 5 — Serialize Write Access with Explicit Transaction Locks

SQLite’s file-level locking means concurrent writers will collide. For automated pipelines that fan out to multiple worker processes, use BEGIN IMMEDIATE to acquire a reserved lock (allowing concurrent reads) before executing spatial DML, and implement exponential backoff for SQLITE_BUSY (database is locked) errors.

python
# Python 3.9+ — retry decorator for SQLite SQLITE_BUSY / "database is locked"
import time
import random
import sqlite3
from functools import wraps

def retry_on_busy(max_retries: int = 6, base_delay_s: float = 0.05):
    """Retry a function on sqlite3.OperationalError 'database is locked'.

    Uses truncated binary-exponential backoff with jitter to avoid thundering
    herd when multiple workers share a single GeoPackage writer.
    """
    def decorator(func):
        @wraps(func)
        def wrapper(*args, **kwargs):
            for attempt in range(max_retries):
                try:
                    return func(*args, **kwargs)
                except sqlite3.OperationalError as exc:
                    if "database is locked" not in str(exc).lower():
                        raise
                    if attempt == max_retries - 1:
                        raise
                    delay = min(base_delay_s * (2 ** attempt), 2.0)
                    delay += random.uniform(0, delay * 0.1)
                    time.sleep(delay)
        return wrapper
    return decorator

@retry_on_busy()
def insert_feature(conn: sqlite3.Connection, table: str, geom_wkb: bytes,
                   attributes: dict) -> None:
    """Insert a single feature inside an IMMEDIATE transaction."""
    cols = ", ".join(["geom"] + list(attributes.keys()))
    placeholders = ", ".join(["?"] * (1 + len(attributes)))
    values = [geom_wkb] + list(attributes.values())
    with conn:
        conn.execute("BEGIN IMMEDIATE;")
        conn.execute(
            f"INSERT INTO {table} ({cols}) VALUES ({placeholders});",
            values,
        )

Validation & Verification

Run these checks after any security configuration change to confirm the controls are effective:

bash
# 1. Confirm the file is not world-readable
stat -c "%a %n" /path/to/survey.gpkg
# Expected: 640 or 600 — not 644 or 777

# 2. Confirm read-only mount prevents writes
sqlite3 /mnt/field-data/survey.gpkg "INSERT INTO gpkg_contents VALUES (null);"
# Expected: Error: attempt to write a readonly database

# 3. Confirm encrypted file cannot be read without the key
python3 -c "
import sqlite3, sys
try:
    c = sqlite3.connect(sys.argv[1])
    c.execute('SELECT count(*) FROM sqlite_master').fetchone()
    print('WARNING: file is unencrypted or key matched empty string')
except sqlite3.DatabaseError:
    print('OK: file is encrypted / inaccessible without SQLCipher')
" /path/to/encrypted.gpkg

# 4. Confirm WAL mode is active (only meaningful on writable connections)
sqlite3 /path/to/writable.gpkg "PRAGMA journal_mode;"
# Expected: wal

From Python, assert the connection mode is enforced:

python
# Python 3.9+ — verify that a read-only connection rejects writes
import sqlite3

def assert_read_only(db_path: str) -> None:
    uri = f"file:{db_path}?mode=ro"
    conn = sqlite3.connect(uri, uri=True)
    try:
        conn.execute("CREATE TABLE _rw_test (id INTEGER PRIMARY KEY);")
        raise AssertionError("Connection accepted a DDL write — mode=ro not enforced")
    except sqlite3.OperationalError as exc:
        assert "readonly" in str(exc).lower(), f"Unexpected error: {exc}"
    finally:
        conn.close()

Common Failure Modes & Fixes

1. attempt to write a readonly database on WAL setup

Symptom: sqlite3.OperationalError: attempt to write a readonly database immediately after opening a mode=ro URI and issuing PRAGMA journal_mode = WAL;.

Cause: Switching journal mode writes the database header. A read-only connection cannot do this — the PRAGMA silently fails on some SQLite builds and raises on others.

Fix: Enable WAL mode from a writable connection before distributing the file. Check in your deployment script:

python
# GeoPackage — enable WAL before making the file read-only
with spatial_db_connection(db_path, read_only=False) as conn:
    result = conn.execute("PRAGMA journal_mode = WAL;").fetchone()[0]
    assert result == "wal", f"WAL not enabled: got {result}"
# Now safe to chmod or remount read-only

2. SQLCipher file is not a database after PRAGMA key

Symptom: pysqlcipher3.dbapi2.DatabaseError: file is not a database even when the passphrase appears correct.

Cause: Common causes: (a) cipher compatibility mismatch between the writing and reading library versions — SQLCipher 3.x and 4.x use different KDF iterations; (b) cipher page size mismatch; © the database was not created with SQLCipher (plain SQLite cannot be opened as encrypted).

Fix:

python
# Diagnose by probing compatibility versions
for compat in (3, 4):
    try:
        c = sqlcipher.connect(db_path)
        c.execute(f"PRAGMA cipher_compatibility = {compat};")
        c.execute(f"PRAGMA key = '{safe_key}';")
        c.execute("SELECT count(*) FROM sqlite_master;")
        print(f"Opened successfully with cipher_compatibility = {compat}")
        c.close()
        break
    except Exception as exc:
        print(f"compat={compat} failed: {exc}")

3. gpkg_geometry_columns missing after bulk insert

Symptom: ogrinfo survey.gpkg reports no layers, or downstream tools cannot find geometry columns, after a Python pipeline inserted rows directly into the feature table.

Cause: The pipeline wrote to the feature table but skipped registering the layer in gpkg_geometry_columns and gpkg_contents. Both tables must be populated for a GeoPackage to be OGC-conformant, as detailed in the GeoPackage Specification Deep Dive.

Fix:

sql
-- GeoPackage SQL — register a feature table that was inserted without metadata
INSERT OR REPLACE INTO gpkg_contents (
    table_name, data_type, identifier, description, last_change, srs_id
) VALUES ('my_features', 'features', 'my_features', '', datetime('now'), 4326);

INSERT OR REPLACE INTO gpkg_geometry_columns (
    table_name, column_name, geometry_type_name, srs_id, z, m
) VALUES ('my_features', 'geom', 'GEOMETRY', 4326, 0, 0);

4. database is locked under concurrent pipeline workers

Symptom: sqlite3.OperationalError: database is locked when multiple processes write to the same GeoPackage simultaneously.

Cause: SQLite’s default busy_timeout is 0 ms — the first SQLITE_BUSY immediately raises an exception. WAL mode reduces contention by allowing concurrent readers, but only one writer holds the lock at a time.

Fix: Set a non-zero busy_timeout and use BEGIN IMMEDIATE to acquire the reserved lock early:

python
# Python 3.9+ — configure busy_timeout for multi-process pipelines
conn = sqlite3.connect(db_path)
conn.execute("PRAGMA busy_timeout = 5000;")  # wait up to 5 s before raising
conn.execute("BEGIN IMMEDIATE;")
# ... DML ...
conn.commit()

5. World-readable permissions after shutil.copy or cloud sync download

Symptom: stat survey.gpkg shows permissions 0o644 or 0o666 after the file was copied or downloaded, even though the original was 0o640.

Cause: shutil.copy and most download helpers set the umask default, not the source file’s permissions.

Fix: Explicitly set permissions after any copy or download operation:

python
import os, shutil, stat

def secure_copy_gpkg(src: str, dst: str, mode: int = 0o640) -> None:
    shutil.copy2(src, dst)
    os.chmod(dst, mode)
    # Verify
    actual = stat.S_IMODE(os.stat(dst).st_mode)
    assert actual == mode, f"chmod failed: got {oct(actual)}"

Performance Notes

  • WAL mode and read-only mounts: WAL mode requires writing -wal and -shm sidecar files in the same directory as the database. On a read-only mount this will fail. If you need WAL-mode concurrency on a read-only volume, copy the database (plus its sidecars) to a writable staging directory before opening connections.
  • Encryption overhead: SQLCipher’s AES-256 adds measurable overhead for large geometry BLOBs — typically 5–15% on full-table spatial scans. Setting cipher_page_size = 4096 (matching SQLite’s default) avoids cross-boundary decryption and keeps overhead at the low end.
  • PRAGMA busy_timeout vs. application-level retry: Setting busy_timeout = 5000 (5 seconds) at the SQLite layer is simpler than application-level retry loops for interactive sessions. For automated pipelines where you want fine-grained backoff control, use the retry_on_busy decorator above rather than relying on the PRAGMA timeout.
  • VACUUM and encryption: Running VACUUM on an encrypted SQLCipher database rewrites every page and temporarily requires roughly 2× the database size in free space. Schedule VACUUM during maintenance windows, not as part of an online ingestion pipeline.
  • PRAGMA secure_delete: Setting PRAGMA secure_delete = ON overwrites deleted pages with zeros before returning them to the free list. This is important for sensitive survey data where physical forensic recovery of deleted geometries must be prevented, but it increases write amplification by roughly 30%.

Child Pages