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, 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
| Layer | Mechanism | Scope | Failure if skipped |
|---|---|---|---|
| File & OS | POSIX ACLs, immutable mounts, directory permissions | Who can open the file descriptor | Any authenticated OS user can read/write raw bytes |
| Encryption | SQLCipher AES-256-CBC, SQLite Encryption Extension (SEE) | Data at rest on disk or removable media | Physical or filesystem access exposes all geometry and metadata |
| Application & Connection | Read-only URI mode, BEGIN IMMEDIATE, metadata validation | Scope and integrity of each Python session | Write bugs, partial transactions, and CRS drift corrupt spatial indexes |
Relevant Specification Anchors
- GeoPackage 1.3 (OGC 12-128r18): Requires
gpkg_spatial_ref_sys,gpkg_contents, andgpkg_geometry_columnsto 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=roandmode=rwcparameters 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 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:
# 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:
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 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 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 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 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 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:
# 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 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:
# 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:
# 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:
-- 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 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:
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
-waland-shmsidecar 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_timeoutvs. application-level retry: Settingbusy_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 theretry_on_busydecorator above rather than relying on the PRAGMA timeout.VACUUMand encryption: RunningVACUUMon 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: SettingPRAGMA secure_delete = ONoverwrites 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
- Securing GeoPackage Files for Field Use — SQLCipher key generation,
ATTACH-based encryption migration, key rotation schedules, and SHA-256 integrity verification for offline field devices.
Related
- Core Architecture & Format Standards for Spatial SQLite — parent overview covering storage, extension, and application layers in full.
- GeoPackage Specification Deep Dive — mandatory metadata table schemas and OGC conformance requirements that your write validator must enforce.
- SpatiaLite Metadata Tables Explained —
geometry_columns,spatial_ref_sys, andviews_geometry_columnsrelationships and validation patterns for SpatiaLite databases. - File Structure & Header Analysis — inspect raw SQLite page structure and header fields to diagnose corruption introduced by incomplete write sessions.
- Extension Compatibility in Spatial SQLite — version pinning and platform-specific loading paths for
mod_spatialite, which must be loaded before any spatial function call on an unencrypted connection.