MySQL / MariaDB Integration Guide

This guide covers how to connect a MySQL or MariaDB database to BurnLedger, configure query templates, and set up a read-only database user.


Connection Configuration

BurnLedger connects to MySQL/MariaDB using a standard go-sql-driver DSN.

Format:

<user>:<password>@tcp(<host>:<port>)/<database>?tls=true

The @tcp(<host>:<port>) form is required. The host is extracted from between @tcp( and ) for SSRF validation at registration; a DSN in any other form (@unix(...), or a bare host:port) is rejected with BAD_REQUEST ("invalid MySQL DSN: cannot extract host").

Avoid @ : / ( ) in the password. A MySQL DSN is not a URI, so percent-encoding does not help — the driver splits the DSN on punctuation, and the registration-time host extractor looks for the literal @tcp(. A password containing any of those characters can move the apparent host boundary and cause either a parse failure or a host mismatch. Use a password limited to letters, digits, - and _.

Example:

burnledger_ro:REPLACE-WITH-YOUR-PASSWORD@tcp(db.example.com:3306)/myapp?tls=true

REPLACE-WITH-YOUR-PASSWORD is a placeholder, not a suggestion: choose your own password when you create the user below, and substitute it wherever the placeholder appears in this guide.

Configuration fields:

Field Required Description
dsn Yes MySQL DSN connection string.

TypeScript

const system = await bl.registerSystem({
  name: "users-mysql",
  connectorType: "mysql",
  connectionConfig: {
    dsn: "burnledger_ro:REPLACE-WITH-YOUR-PASSWORD@tcp(db.example.com:3306)/myapp?tls=true",
  },
  subjectQuery: "SELECT * FROM users WHERE email = ?",
});

Python

system = bl.register_system(
    name="users-mysql",
    connector_type="mysql",
    connection_config={
        "dsn": "burnledger_ro:REPLACE-WITH-YOUR-PASSWORD@tcp(db.example.com:3306)/myapp?tls=true",
    },
    subject_query="SELECT * FROM users WHERE email = ?",
)

Connection requirements:

Requirement Details
TLS ?tls=true is required, and the host needs an explicit port (@tcp(db.example.com:3306)). Without a port the driver derives no ServerName on the production dial path and the config cannot be classified, which is refused. tls=skip-verify (encrypted but the certificate is never checked), tls=preferred (silently falls back to cleartext when the server clears one capability bit), a DSN with no tls= at all, and tls=custom (no custom TLS config is registered, so the DSN fails to parse) are all refused before the connector is built. For a private CA, supply the root as ca_cert alongside tls=true. Enforce TLS server-side too with REQUIRE SSL on the account. See connector transport security.
Network access Add BurnLedger's static IPs to your firewall or security group. The DSN host must be publicly resolvable — localhost, .local/.internal names, and private/loopback addresses are rejected at registration.
Permissions Read-only. BurnLedger refuses to connect if the user has write privileges.
Character set UTF-8 (utf8mb4) is enforced: the connector appends charset=utf8mb4 to the DSN unless a charset parameter is already present.
Timeout 10s connection, 30s query.

Private CA (ca_cert)

tls=true verifies the server certificate against the system trust store. If your server's verification record is signed by a private or internal CA, that check fails with x509: certificate signed by unknown authority. Supply the root in the connection config as ca_cert — base64 of the PEM bundle, snake_case in both SDKs:

base64 -w0 ca.pem      # macOS: base64 -i ca.pem | tr -d '\n'
{
  "dsn": "burnledger_ro:pw@tcp(db.example.com:3306)/myapp?tls=true",
  "ca_cert": "LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0t..."
}

ca_cert does not change the transport classification: a custom trust root is still verification, so the config stays verified and is admitted by the ADR-012 floor. It also never turns TLS on — supplying it alongside a DSN that puts no TLS on the data path is refused when the connector is built, rather than connecting with a trust root that does nothing.

Keep tls=true: tls=skip-verify verifies nothing (a supplied root would be inert), and a DSN with no tls= is refused outright when a ca_cert is present.


Query Template Format

MySQL query templates use ? as the parameter placeholder (standard MySQL prepared statement syntax).

Rules (enforced at registration; a template that fails any of them is rejected with INVALID_QUERY_TEMPLATE):

  • Must begin with SELECT or WITH (WITH … SELECT CTEs are accepted).
  • Must contain a ? placeholder. Without it the template is rejected — it is never silently run unparameterized.
  • Must contain a WHERE clause. A template with no WHERE is rejected, not warned about.
  • Must be a single statement: no embedded ;.
  • Must not contain any write, DDL, locking, or session keyword anywhere in the text, including inside a CTE (INSERT UPDATE DELETE REPLACE TRUNCATE DROP CREATE ALTER GRANT REVOKE INTO CALL EXECUTE LOAD LOCK SET PREPARE BEGIN COMMIT ROLLBACK, among others).
  • Must not use FOR UPDATE / FOR SHARE locking clauses.

At execution time every query additionally runs inside a START TRANSACTION READ ONLY, so anything that slips past static validation still fails at the engine. SELECT * is allowed but produces a warning.

Examples:

-- Simple lookup
SELECT * FROM users WHERE email = ?

-- Join across tables
SELECT u.id, u.email, o.order_id, o.total
FROM users u
LEFT JOIN orders o ON o.user_id = u.id
WHERE u.email = ?

-- Using a unique external ID
SELECT * FROM customers WHERE external_id = ?

Read-Only User Setup

-- Create a read-only user
CREATE USER 'burnledger_ro'@'%' IDENTIFIED BY 'REPLACE-WITH-YOUR-PASSWORD';

-- Grant SELECT on specific tables
GRANT SELECT ON myapp.users TO 'burnledger_ro'@'%';
GRANT SELECT ON myapp.orders TO 'burnledger_ro'@'%';

-- Or grant SELECT on all tables in the database
GRANT SELECT ON myapp.* TO 'burnledger_ro'@'%';

-- Apply
FLUSH PRIVILEGES;

Verify

-- Connect as read-only user
mysql -u burnledger_ro -p -h db.example.com myapp

-- Should succeed
SELECT * FROM users LIMIT 1;

-- Should fail with "INSERT command denied"
INSERT INTO users (email) VALUES ('test@test.com');

What the privilege check accepts

BurnLedger runs SHOW GRANTS FOR CURRENT_USER() — it never writes to verify. Each grant line's privilege list is parsed, and only SELECT and USAGE are accepted. Everything else marks the credential as write-capable and construction is refused with WRITE_ACCESS_DETECTED — including privileges that are not obviously writes:

  • ALL PRIVILEGES (caught by the same rule)
  • CREATE TEMPORARY TABLES, LOCK TABLES
  • PROCESS, RELOAD, REPLICATION CLIENT, REPLICATION SLAVE, SUPER
  • SHOW VIEW, SHOW DATABASES, INDEX, REFERENCES, TRIGGER, EVENT

So grant the account SELECT and nothing else. Column-scoped grants (SELECT (col1, col2)) are handled correctly, and pure role grants (GRANT 'somerole' TO 'user'@'%', with no ON clause) are ignored — but any table privileges the role itself carries do show up in SHOW GRANTS once the role is activated, and are checked.

MariaDB

The same SQL applies. MariaDB uses the same privilege system as MySQL.

Amazon RDS / Aurora

Use the RDS console or CLI to create a user, then run the GRANT statements above. Ensure the security group allows inbound connections from BurnLedger's IPs.


Endpoint role

POST /v1/systems/test-connection reports, under replication, whether this endpoint accepts writes: @@global.read_only and @@global.super_read_only, which any account may read. read_only ON is how a replica in a managed topology presents itself.

Replication lag is not reported, and will not be. Seconds_Behind_Source comes from SHOW REPLICA STATUS, which needs REPLICATION CLIENT — one of the privileges listed above that marks a credential write-capable and gets it refused. Asking a customer to widen a grant so BurnLedger can print a nicer diagnostic is the wrong trade, so the field says unknown and says why. A server without super_read_only (MariaDB) also reports unknown. See endpoint role.


Hash Scope Options

Same as PostgreSQL:

Scope Behavior
full Hashes complete row content. Detects any change.
existence Records only whether rows matched and how many. Detects insert/delete but not updates, and never reads column values.

hash_scope defaults to existence when omitted.


Troubleshooting

Error Cause Fix
CONNECTION_FAILED Cannot reach MySQL host Verify DSN, check firewall, ensure TLS is enabled.
WRITE_ACCESS_DETECTED The account holds any privilege other than SELECT/USAGE — not just INSERT/UPDATE/DELETE Revoke everything except SELECT: REVOKE ALL PRIVILEGES ON *.* FROM 'burnledger_ro'@'%'; GRANT SELECT ON myapp.* TO 'burnledger_ro'@'%'; Check with SHOW GRANTS FOR 'burnledger_ro'@'%';.
INVALID_QUERY_TEMPLATE (HTTP 422) Query is not a single read-only SELECT, or is missing ? or WHERE Rewrite per Query Template Format.
BAD_REQUEST "invalid MySQL DSN: cannot extract host" DSN is not in the user:pass@tcp(host:port)/db form Rewrite the DSN using @tcp(...).
CONNECTION_FAILED on attestation: "connector mysql: transport security plaintext/encrypted is below the required minimum verified". A health check reports only connection failed — read transport_security on the system No tls= parameter, tls=false, tls=preferred (plaintext), or tls=skip-verify (encrypted, certificate never checked) Use ?tls=true.
CONNECTION_FAILED on attestation: "connector mysql: transport security could not be classified and is never assumed secure; the required minimum is verified". A health check reports only connection failed — read transport_security on the system The DSN host has no explicit port (no ServerName can be derived), or tls=custom was used Add the port — @tcp(db.example.com:3306) — and use tls=true.
© 2026 ProChatFlow LLC Last updated present → absent → proven