Oracle Database Integration Guide

This guide covers how to connect an Oracle Database to BurnLedger, configure query templates, set up a read-only database user, and choose the right hash scope for your compliance needs.

BurnLedger uses the pure-Go go-ora driver, so no Oracle Instant Client, TNS admin directory, or tnsnames.ora entry is involved — the whole connection is expressed as a single URL-style DSN.


Connection Configuration

The Oracle connector takes exactly one configuration field, dsn. No other key is read. The connection config is decoded as a flat string→string object, so extra string keys are ignored, but a non-string value anywhere in the object (for example "read_only": true, which this connector does not use) fails decoding with invalid config JSON when BurnLedger connects.

Key Type Required Description
dsn string Yes Oracle connection URL: oracle://<user>:<password>@<host>:<port>/<service_name>?SSL=true

Format:

oracle://<user>:<password>@<host>:<port>/<service_name>?SSL=true

Percent-encode special characters in the password. The DSN is parsed as a URI both by BurnLedger's host validation and by the driver, so characters such as @ : / ? # must be escaped (@%40, :%3A, /%2F, ?%3F, #%23). An unescaped @ splits the URI at the wrong place, and BurnLedger then validates — or rejects — the wrong host. If you control the credential, the simplest option is a password limited to letters, digits, - and _.

Example:

oracle://burnledger_ro:REPLACE-WITH-YOUR-PASSWORD@oracle.example.com:1521/FREEPDB1?SSL=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.

Service name, not SID

The path segment after the host and port is a service name, not a SID. On a container database this is normally the pluggable database's service — FREEPDB1 on Oracle Free 23c, ORCLPDB1 on many 19c installs — not the CDB instance name (FREE, ORCL). Confirm it with:

SELECT name FROM v$services;
-- or, from the database host:
-- lsnrctl services

Pointing the DSN at a SID where the listener expects a service name produces ORA-12514 ("listener does not currently know of service requested"), surfaced by BurnLedger as CONNECTION_FAILED.

Apart from one addition described below, BurnLedger passes the DSN to the driver unchanged, so driver-level query parameters you append are preserved. Only the service-name form has been verified end to end against a live database.

The CID parameter

BurnLedger appends a CID query parameter to the DSN if you have not set one:

CID=(CID=(PROGRAM=burnledger)(HOST=burnledger-enclave)(USER=burnledger))

This is required because the attestation enclave has no kernel hostname; without an explicit CID the driver emits (HOST=(none)), which Oracle's TNS descriptor parser rejects with TNS-01153 / ORA-12564. If you supply your own CID, it is used verbatim — make sure it is a well-formed, parenthesis-balanced TNS client identifier. In Oracle audit trails, sessions from BurnLedger appear under program name burnledger.

When registering the system via the SDK:

TypeScript

const system = await bl.registerSystem({
  name: "users-db",
  connectorType: "oracle",
  connectionConfig: {
    dsn: "oracle://burnledger_ro:REPLACE-WITH-YOUR-PASSWORD@oracle.example.com:1521/FREEPDB1?SSL=true",
  },
  subjectQuery: "SELECT ID, EMAIL, PHONE FROM APPOWNER.USERS WHERE EMAIL = :1",
});

Python

system = bl.register_system(
    name="users-db",
    connector_type="oracle",
    connection_config={
        "dsn": "oracle://burnledger_ro:REPLACE-WITH-YOUR-PASSWORD@oracle.example.com:1521/FREEPDB1?SSL=true",
    },
    subject_query="SELECT ID, EMAIL, PHONE FROM APPOWNER.USERS WHERE EMAIL = :1",
)

Connection requirements:

Requirement Details
Transport security ?SSL=true is required, and the endpoint must be a TCPS listener with a verification record that chains to a trusted root. SSL=true is what sets the protocol to tcps — the URL scheme is ignored by the driver, so tcps:// or oracles:// on their own change nothing. Leave SSL VERIFY at its default (true); SSL VERIFY=false encrypts without validating the verification record and is refused, as is a plain oracle:// DSN with no SSL=true. Oracle Native Network Encryption (ENCRYPTION=REQUIRED) does not satisfy this: the server picks the algorithm and no peer is authenticated. A DSN that sets both connStr and server is refused as unclassifiable, because the driver's endpoint set is then order-dependent. For a private or internal CA, supply the root as ca_cert — a wallet is a directory inside the process that dials, which the hosted deployment cannot receive. See connector transport security.
Network access BurnLedger connects from a fixed egress address. Allowlist it on the listener port in your firewall or security group — that is the TCPS listener's port (commonly 2484, or 1521 if your listener serves TCPS there), not a cleartext TCP one.
Host policy The DSN host is resolved and checked before connecting; private, loopback, and link-local addresses are refused in production deployments.
Permissions Read-only. BurnLedger introspects the credential's privileges at connect time and refuses to connect if any of them can write. See Read-Only User Setup below.
Transaction mode Every attestation query runs on a pinned connection after SET TRANSACTION READ ONLY, so any write a template tried to perform fails at the engine level.
Timeout Queries must complete within the system's query_timeout (default 30 seconds).

Private CA (ca_cert)

A TCPS listener whose verification record is signed by an internal CA — the usual arrangement for on-prem Oracle — cannot be verified against the system trust store, and the handshake 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": "oracle://burnledger_ro:pw@oracle.example.com:2484/FREEPDB1?SSL=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.

Two Oracle-specific restrictions come from the driver, and both are refusals rather than surprises:

  • Exactly one server address. go-ora rewrites the TLS server name on the shared configuration at every dial, so with several addresses (a second ?server=, or a descriptor listing an ADDRESS_LIST) verification would depend on which dial won the race. A ca_cert on such a DSN is refused.
  • SSL VERIFY must stay at its default true. Handing the driver a trust root bypasses its own verify switch, so a DSN asking both for a root and for no verification cannot be honoured as written and is refused.

Query Template Format

A query template is a SELECT statement with :1 as the placeholder for the data subject identifier. Oracle binds positional parameters as :1, :2, …, and BurnLedger supplies exactly one bind value — the subject identifier — using a parameterized query (never string interpolation).

:1 is the only placeholder that works here. There is no universal placeholder across BurnLedger connectors: PostgreSQL uses $1, MySQL uses ?, Oracle uses :1. A template with no recognized placeholder is rejected at registration with INVALID_QUERY_TEMPLATE (HTTP 422). This rejection is deliberate: a template that never binds the subject matches nothing, and a silently empty result would produce a signed verification record asserting that the subject has no records — a cryptographically valid false negative.

The registration validator also tolerates a literal ? for historical reasons, but the Oracle driver does not bind ?. Such a template registers and then fails at attestation time with an Oracle syntax/bind error. Always write :1.

Rules:

  • Must be a single statement beginning with SELECT (or WITH … SELECT).
  • Must contain a WHERE clause — an unbounded full-table scan is rejected.
  • Must contain the :1 placeholder. Exactly one bind value — the subject identifier — is supplied, so :1 is the only bind the template may reference; a :2 (or a named bind such as :email) has no value and fails at attestation time with ORA-01008.
  • Must not contain an embedded ; — a trailing semicolon is not needed and breaks the inline view BurnLedger wraps the template in (ORA-00933). Leave it off.
  • Must not contain write keywords (INSERT, UPDATE, DELETE, MERGE, TRUNCATE, CREATE, DROP, ALTER, …), including inside a WITH clause.
  • Must not use FOR UPDATE — row locks are a write effect.

Examples:

-- Single table, subject identified by email
SELECT ID, EMAIL, NAME, CREATED_AT FROM APPOWNER.USERS WHERE EMAIL = :1

-- Subject identified by an external ID
SELECT ID, NAME, EMAIL, PHONE FROM APPOWNER.CUSTOMERS WHERE EXTERNAL_ID = :1

-- Join across tables for a complete subject record
SELECT u.ID, u.EMAIL, p.STREET, p.CITY, p.COUNTRY
FROM APPOWNER.USERS u
LEFT JOIN APPOWNER.PROFILES p ON p.USER_ID = u.ID
WHERE u.EMAIL = :1

-- Case-insensitive match on a normalized column
SELECT ID, EMAIL FROM APPOWNER.USERS WHERE LOWER(EMAIL) = LOWER(:1)

Schema qualification. The read-only user owns no objects, so unqualified names resolve against its own (empty) schema and fail with ORA-00942. Qualify every table with its owner (APPOWNER.USERS) or create private synonyms for the read-only user.

Column naming and stability. Every returned column is read as a nullable string and hashed together with its column name, and Oracle reports unquoted column names in upper case. Fields are sorted by column name before the row is hashed, so reordering the select list does not change a record hash — but renaming or aliasing a column, adding or removing one, or changing a value does. List columns explicitly (a SELECT * is accepted but produces a warning, and its column set can drift with the table) and alias expressions deterministically:

SELECT ID, TO_CHAR(CREATED_AT, 'YYYY-MM-DD"T"HH24:MI:SS') AS CREATED_AT
FROM APPOWNER.USERS WHERE EMAIL = :1

Cast binary and LOB columns explicitly (RAWTOHEX(...), TO_CHAR(...)) rather than selecting them raw — the connector reads results as text.

Important: Design your query to return all records associated with the data subject that you need to prove exist (or have been deleted). If a subject's data spans multiple schemas or databases, use joins or register multiple systems.


Read-Only User Setup

BurnLedger requires a dedicated read-only Oracle user. It does not take your word for it and there is no read_only configuration flag for this connector: at connect time it introspects the credential's effective privileges and aborts with WRITE_ACCESS_DETECTED if it finds any that can write. Nothing is ever written to your database — the check is pure introspection of two data-dictionary views that any session can read:

View What BurnLedger accepts Anything else means write-capable
SESSION_PRIVS (system privileges active in the session, including those from default roles) CREATE SESSION, SELECT ANY TABLE, SELECT ANY DICTIONARY, READ ANY TABLE CREATE TABLE, CREATE ANY TABLE, INSERT/UPDATE/DELETE ANY TABLE, DROP ANY …, ALTER ANY …, UNLIMITED TABLESPACE, …
USER_TAB_PRIVS WHERE GRANTEE = USER (object privileges granted directly to the user) SELECT, READ INSERT, UPDATE, DELETE, ALTER, INDEX, REFERENCES, EXECUTE, …

Two consequences worth planning for:

  • Do not grant RESOURCE, DBA, CONNECT, or UNLIMITED TABLESPACE. Those bring system privileges into SESSION_PRIVS and the connection is refused. CONNECT is not a safe shortcut: on 12c and later it also carries SET CONTAINER, which is outside the accepted set, so a user granted CONNECT is rejected with WRITE_ACCESS_DETECTED. Grant CREATE SESSION directly — it is the only system privilege the connector needs.
  • Grant SELECT directly to the user, not through a role. The object-privilege check reads USER_TAB_PRIVS for grants made directly to the user; privileges reaching the user only via a role are not visible there, so a role-based grant may pass the check and then fail the query with ORA-00942.

Create the user

Run as a DBA inside the pluggable database that holds your data. In a CDB, connect to the PDB first — creating a local user from the root fails with ORA-65096 unless the name starts with C##:

ALTER SESSION SET CONTAINER = FREEPDB1;

-- Create the account. Quote the password if it contains anything but
-- letters, digits and underscore.
CREATE USER burnledger_ro IDENTIFIED BY "REPLACE-WITH-YOUR-PASSWORD";

-- The only system privilege it needs: the ability to log in.
GRANT CREATE SESSION TO burnledger_ro;

-- Read access to exactly the tables your query template touches.
GRANT SELECT ON APPOWNER.USERS    TO burnledger_ro;
GRANT SELECT ON APPOWNER.PROFILES TO burnledger_ro;

Do not add a tablespace quota, RESOURCE, or any ANY-scoped write privilege. SELECT ANY TABLE / READ ANY TABLE are accepted if your policy prefers them over per-table grants, but per-table grants are the tighter option.

Verify the permissions

Connect as burnledger_ro and run exactly what BurnLedger runs:

-- Expect: only CREATE SESSION (plus, optionally, SELECT ANY TABLE /
-- SELECT ANY DICTIONARY / READ ANY TABLE). Anything else is rejected.
SELECT PRIVILEGE FROM SESSION_PRIVS;

-- Expect: only SELECT or READ rows.
SELECT PRIVILEGE, OWNER, TABLE_NAME FROM USER_TAB_PRIVS WHERE GRANTEE = USER;

-- The query itself must succeed...
SELECT COUNT(*) FROM APPOWNER.USERS WHERE ROWNUM <= 1;

-- ...and writes must fail with ORA-01031 / ORA-00942.
INSERT INTO APPOWNER.USERS (ID) VALUES (-1);
UPDATE APPOWNER.USERS SET EMAIL = 'x' WHERE ID = -1;
DELETE FROM APPOWNER.USERS WHERE ID = -1;

Revoke write privileges if they exist

REVOKE RESOURCE FROM burnledger_ro;
REVOKE UNLIMITED TABLESPACE FROM burnledger_ro;
REVOKE INSERT, UPDATE, DELETE ON APPOWNER.USERS FROM burnledger_ro;
ALTER USER burnledger_ro QUOTA 0 ON USERS;

Re-run the two verification queries above; when they return only the accepted privileges, BurnLedger will connect.


Hash Scope Options

The hashScope parameter is recorded on the system and carried into every verification record, so a verifier can see what the hashes cover.

Every column returned by the template is canonicalized with its column name (a NULL is encoded with a distinct null sentinel, so NULL and an empty string hash differently) and the row is hashed with SHA-256. Any change to any selected column value produces a different hash.

const system = await bl.registerSystem({
  // ...
});

Use when: You need to prove the exact data that existed, or prove that data was deleted and not merely modified (e.g., nullifying PII columns is not the same as deletion under some interpretations of GDPR).

existence (default when omitted)

Declares that the attestation is about whether records exist and how many, not about their content.

const system = await bl.registerSystem({
  // ...
  hashScope: "existence",
});

Use when: You only need to prove that records existed and were later removed.

How it interacts with proof mode

For Oracle, what BurnLedger actually executes is chosen by the attestation's proof mode, not by the hash scope:

Proof mode What runs What lands in the verification record
count SELECT COUNT(*) over your template, bounded by ROWNUM Record count only — no row content leaves the database
merkle Your template, bounded by ROWNUM, one SHA-256 per row Record count + Merkle root over the per-row hashes

Row hashing under merkle always covers the full canonicalized row for SQL connectors. If you do not want row content to influence any hash, use proof mode count.

Comparison

full existence
Detects row deletion Yes Yes
Detects row insertion Yes Yes
Detects column value changes Yes (merkle root only; see the note below the table) Not asserted
Hash includes row content Yes (with merkle proofs) No, when used with count
Recommended for GDPR Art. 17 Yes Acceptable

"Detects" here is bounded by what a merkle certificate actually carries: a signed Merkle root over the row hashes and nothing else — BurnLedger serves no per-record inclusion proof, so a changed value is demonstrable only to someone who already holds every matching row and rebuilds the tree, and from format 7.0 the leaves are keyed inside the enclave so no party, BurnLedger included, can open the root at all (see Certificate Scope §4).


End-to-End Example

This example walks through the full process: creating a read-only user, registering the system, creating an attestation before deletion, performing the deletion, verifying, and generating a verification record.

1. Database setup (run once)

-- As a DBA, inside the PDB that holds the data
ALTER SESSION SET CONTAINER = FREEPDB1;

CREATE USER burnledger_ro IDENTIFIED BY "REPLACE-WITH-YOUR-PASSWORD";
GRANT CREATE SESSION TO burnledger_ro;
GRANT SELECT ON APPOWNER.USERS    TO burnledger_ro;
GRANT SELECT ON APPOWNER.PROFILES TO burnledger_ro;
GRANT SELECT ON APPOWNER.ORDERS   TO burnledger_ro;

2. Application code

TypeScript

import { BurnLedger } from "burnledger";
import fs from "node:fs/promises";

const bl = new BurnLedger({
  apiKey: process.env.BURNLEDGER_API_KEY,
});

// Register the system (run once, store the system ID)
const system = await bl.registerSystem({
  name: "myapp-users-oracle",
  connectorType: "oracle",
  connectionConfig: {
    dsn: "oracle://burnledger_ro:REPLACE-WITH-YOUR-PASSWORD@oracle.example.com:1521/FREEPDB1?SSL=true",
  },
  subjectQuery:
    "SELECT u.ID, u.EMAIL, u.NAME, p.PHONE, p.ADDRESS FROM APPOWNER.USERS u LEFT JOIN APPOWNER.PROFILES p ON p.USER_ID = u.ID WHERE u.EMAIL = :1",
});

// --- When a deletion request comes in ---

const subjectEmail = "jane.doe@example.com";

// Step 1: Attest that the data currently exists
const attestation = await bl.attest(subjectEmail, {
  systemIds: [system.id],
  proofMode: "merkle",
});

for (const s of attestation.systems) {
  console.log(`${s.systemName}: ${s.recordCount} records found`);
}

// Step 2: Delete the data in your application
// await conn.execute("DELETE FROM APPOWNER.PROFILES WHERE USER_ID = (SELECT ID FROM APPOWNER.USERS WHERE EMAIL = :1)", [subjectEmail]);
// await conn.execute("DELETE FROM APPOWNER.USERS WHERE EMAIL = :1", [subjectEmail]);
// await conn.commit();

// Step 3: Verify the deletion
// The certificate is ISSUED BY verify() once the data is gone -- there is
// no separate "create certificate" step.
const result = await bl.verify(attestation.id, subjectEmail, { timeout: 60 });

for (const s of result.systems) {
  console.log(`${s.systemName}: now ${s.recordCount} records`);
}

if (result.certificate) {
  await bl.savePdf(result.certificate.id, `erasure-cert-${subjectEmail}.pdf`);
  console.log(`Certificate issued: ${result.certificate.id}`);
  console.log(`Transparency status: ${result.certificate.transparencyStatus}`);
} else {
  console.log(`Not certified: status ${result.status}`);
}

Python

import os
from burnledger import BurnLedger

bl = BurnLedger(api_key=os.environ["BURNLEDGER_API_KEY"])

# Register the system (run once, store the system ID)
system = bl.register_system(
    name="myapp-users-oracle",
    connector_type="oracle",
    connection_config={
        "dsn": "oracle://burnledger_ro:REPLACE-WITH-YOUR-PASSWORD@oracle.example.com:1521/FREEPDB1?SSL=true",
    },
    subject_query=(
        "SELECT u.ID, u.EMAIL, u.NAME, p.PHONE, p.ADDRESS "
        "FROM APPOWNER.USERS u LEFT JOIN APPOWNER.PROFILES p ON p.USER_ID = u.ID "
        "WHERE u.EMAIL = :1"
    ),
)

# --- When a deletion request comes in ---

subject_email = "jane.doe@example.com"

# Step 1: Attest that the data currently exists
attestation = bl.attest(
    subject_email,
    system_ids=[system.id],
    proof_mode="merkle",
)

for s in attestation.systems:
    print(f"{s.system_name}: {s.record_count} records found")

# Step 2: Delete the data in your application
# cursor.execute("DELETE FROM APPOWNER.PROFILES WHERE USER_ID = (SELECT ID FROM APPOWNER.USERS WHERE EMAIL = :1)", [subject_email])
# cursor.execute("DELETE FROM APPOWNER.USERS WHERE EMAIL = :1", [subject_email])
# connection.commit()

# Step 3: Verify the deletion
# The certificate is ISSUED BY verify() once the data is gone -- there is
# no separate "create certificate" step.
result = bl.verify(attestation.id, subject_email, timeout=60)

for s in result.systems:
    print(f"{s.system_name}: now {s.record_count} records")

if result.certificate:
    bl.save_pdf(result.certificate.id, f"erasure-cert-{subject_email}.pdf")
    print(f"Certificate issued: {result.certificate.id}")
    print(f"Transparency status: {result.certificate.transparency_status.value}")
else:
    print(f"Not certified: status {result.status.value}")

Troubleshooting

Error Cause Fix
CONNECTION_FAILED with missing required config field "dsn" (or invalid config JSON) Connection config used another key name, or a non-string value The Oracle connector reads only dsn, as a string. Note that registration itself is not blocked by this — the system is stored with a failed health status and every attestation against it fails until the config is corrected.
CONNECTION_FAILED with ORA-12514 The DSN path is a SID, or the service name is wrong Use the service name (e.g. FREEPDB1); confirm with SELECT name FROM v$services or lsnrctl services.
CONNECTION_FAILED with ORA-01017 Wrong user/password — often an unescaped special character in the password Percent-encode @ : / ? # in the DSN password.
CONNECTION_FAILED / blocked or invalid host The DSN could not be parsed, or resolved to a private/loopback address Check the URI syntax (again, password encoding) and that the host is publicly resolvable; allowlist the BurnLedger IPs on port 1521.
CONNECTION_FAILED with ORA-12564 or TNS-01153 A custom CID parameter in your DSN is malformed Remove it and let BurnLedger supply its own, or provide a balanced (CID=(PROGRAM=…)(HOST=…)(USER=…)).
CONNECTION_FAILED on attestation: "connector oracle: transport security plaintext is below the required minimum verified". A health check reports only connection failed — read transport_security on the system The DSN has no SSL=true, so the driver uses the tcp protocol regardless of the URL scheme Add ?SSL=true and point the DSN at a TCPS listener.
CONNECTION_FAILED on attestation: "connector oracle: transport security encrypted is below the required minimum verified". A health check reports only connection failed — read transport_security on the system SSL VERIFY=false — TLS with no certificate validation Remove it (the driver default is true) and use a listener certificate that chains to a trusted root.
CONNECTION_FAILED with "failed to verify credential privileges" The user cannot read SESSION_PRIVS or USER_TAB_PRIVS Both are normally readable by any session; check for a logon trigger, restricted session, or a hardened profile blocking dictionary access.
WRITE_ACCESS_DETECTED The user holds a system privilege outside CREATE SESSION / SELECT ANY TABLE / SELECT ANY DICTIONARY / READ ANY TABLE, or an object privilege other than SELECT / READ Revoke it (commonly CONNECT — which carries SET CONTAINER on 12c+ — RESOURCE, UNLIMITED TABLESPACE, or a stray INSERT). Registration is rejected outright in this case, not merely marked unhealthy. See Read-Only User Setup.
INVALID_QUERY_TEMPLATE (422) with "does not contain :1" The template has no subject placeholder Bind the subject with :1 — not $1, ?, :name, or {identifier}.
INVALID_QUERY_TEMPLATE (422) with "no WHERE clause" The template would scan the whole table Add a WHERE <column> = :1 predicate.
INVALID_QUERY_TEMPLATE (422) with "single statement" / "disallowed keyword" Embedded ;, or a write keyword anywhere in the template Submit one SELECT (or WITH … SELECT), no trailing semicolon, no DML.
ORA-00933 at attestation time Trailing semicolon in the template — it is wrapped in an inline view Remove the semicolon.
ORA-00942 at attestation time Table not qualified with its owner, or SELECT was granted via a role Qualify as OWNER.TABLE and grant SELECT directly to the read-only user.
ORA-01008 at attestation time The template references a bind other than :1 (e.g. :2, or a named bind like :email) Bind only :1; only the subject identifier is supplied.
RECORD_LIMIT_EXCEEDED The query matched more rows than max_records (default 1,000,000) Narrow the template, or raise max_records up to the plan limit.
CONNECTION_FAILED with "query timed out" The query exceeded query_timeout (default 30s) Index the predicate column, or raise query_timeout at registration.
QUERY_HASH_MISMATCH during verification The stored template changed, or data changed between attestation and verification Expected if you deleted or modified records — review the changes array. If you edited the template, register a new system.
© 2026 ProChatFlow LLC Last updated present → absent → proven