Security
DBBat implements multiple security layers to protect both the proxy infrastructure and the target databases.
Authentication
Password Hashing
User passwords are hashed using Argon2id, the winner of the Password Hashing Competition:
- Memory-hard algorithm resistant to GPU/ASIC attacks
- Configurable memory, time, and parallelism parameters
- Includes salt to prevent rainbow table attacks
Password Requirements
- Mandatory change: Users must change their initial password before accessing the API
- Minimum length: 8 characters (configurable)
- Login attempts before password change return
403 password_change_required
Authentication Rate Limiting
Failed login attempts trigger exponential backoff per username:
| Failed Attempts | Lockout Duration |
|---|---|
| 1-2 | None |
| 3-4 | 5 seconds |
| 5-6 | 30 seconds |
| 7-9 | 2 minutes |
| 10+ | 5 minutes |
This prevents brute-force attacks while allowing legitimate users to recover from typos.
Token Types
| Type | Prefix | Lifetime | Use Case |
|---|---|---|---|
| Web Session | web_ | 1 hour | Interactive frontend use |
| API Key | dbb_ | Configurable (or permanent) | Programmatic access |
How Keys Are Stored
API keys and web session tokens are hashed with Argon2id, through the exact
same path as user passwords. What the api_keys table holds is:
key_hash— the Argon2id hash of the full tokenkey_prefix— the first 8 characters in clear, used only to find the candidate row before verifying the hash
So a key is not recoverable from the database: DBBat cannot show it to you again after creation, and a leaked dump yields no usable key.
Two related nuances, so the picture is complete:
- A freshly minted key is held AES-256-GCM-encrypted inside a pending device-authorization request, bound to that request's UID, for the few minutes between the user approving the device and the device polling for the token. That ciphertext is the only place a plaintext key is ever recoverable.
- For Oracle, the O5LOGON verifier material derived from a key is stored encrypted (AES-256-GCM, bound to the key prefix), because the O5LOGON challenge cannot be answered from a password hash. That is verifier material, not the key itself.
API Key Restrictions
API keys have intentional limitations:
- Cannot create other API keys
- Cannot revoke API keys
- These operations require web session or basic auth
This prevents a compromised API key from being used to create persistent backdoor access.
Encryption
Database Credentials
Target database passwords are encrypted at rest using AES-256-GCM:
- 256-bit encryption keys
- Authenticated encryption (integrity + confidentiality)
- Random nonce per encryption operation
- AAD binding: Ciphertext is bound to the database ID, preventing credential transplant attacks
Key Management
Encryption keys are provided via environment:
| Variable | Description |
|---|---|
DBB_KEY | Base64-encoded 32-byte key |
DBB_KEYFILE | Path to file containing the key |
Keys are:
- Never logged or exposed via API
- Never transmitted over the network
- Required at startup (DBBat won't start without a valid key)
Role-Based Access Control
Roles
| Role | Description |
|---|---|
admin | Full access to all resources and operations |
viewer | Read-only access to observability data (queries, connections, audit) |
connector | Can only connect to servers with active grants |
Users can have multiple roles. Permissions are additive.
Resource Visibility by Role
| Resource | Admin | Viewer | Connector |
|---|---|---|---|
| All users | Full | List only | Own only |
| All servers | Full details | Name/description | Granted only |
| SSH servers | Full details | None | None |
| All grants | Full | Full | Own only |
| All queries | Full | Full | None |
| All connections | Full | Full | Own only |
| Audit log | Full | Full | None |
| API keys | Own keys (all users with ?all_users=true) | Own keys | Own keys |
SSH servers are admin-only. A server with protocol: ssh is a bastion definition, not a proxied target: it can never be the target of a grant, and non-admins never see it. Its only role is to be referenced by another server's via_uid.
API key listing is caller-scoped. GET /api/v1/keys returns only the calling user's own keys, including for admins. Admins can pass ?all_users=true to list every user's keys.
Access Grants
Grants control which users can connect to which servers through the proxy.
Grant Constraints
A grant is an instance of a grant definition: the time window and revocation state live on the grant, everything below lives on the definition it was issued from and is read back from there.
| Constraint | Lives on | Description |
|---|---|---|
starts_at | grant | Grant is not valid before this time |
expires_at | grant | Grant automatically expires after this time |
max_query_counts | definition | Maximum queries allowed (quota) |
max_bytes_transferred | definition | Maximum data transfer allowed (quota) |
controls | definition | Combination of read_only, block_copy, block_ddl. Empty = full write access. |
Recommendation: Always set all constraints. Time-limited grants with quotas minimize blast radius if credentials are compromised.
Because the rules live on the definition and definitions are immutably versioned, a grant's behaviour is fixed at issue time and the set of definitions is an auditable list of every access shape in use — no grant can be an unreviewable one-off.
Grant Lifecycle
- Admin assigns a grant definition to a user and database (or approves a request for one)
- Grant becomes active at
starts_at - User can connect and execute queries
- Quotas and the time window are enforced mid-stream, not only between commands
- Grant expires at
expires_ator when revoked - Revoking a grant blocks further queries and disconnects sessions already live under it, across all protocols
- Revoked grants record
revoked_atandrevoked_byfor audit
Mid-stream limit enforcement
Grant time windows and byte quotas are checked continuously while data flows, not only at the boundary between commands. A single long query that streams past its remaining time or byte budget is cut off partway through its result set rather than being allowed to run to completion.
Bytes already transferred by such an aborted query are still persisted, so quota accounting stays accurate even when the query never finished.
The same applies to revocation: revoking a grant does not merely refuse new connections — sessions already established under that grant are torn down.
Upstream Identity
DBBat encodes the acting DBBat username into the upstream connection metadata, so monitoring on the database side attributes queries to the real human rather than to a shared service account:
| Engine | Field carrying the DBBat username |
|---|---|
| PostgreSQL | application_name |
| MySQL / MariaDB | program_name |
| Oracle | AUTH_PROGRAM_NM |
| SQL Server | LOGIN7 AppName (visible as sys.dm_exec_sessions.program_name) |
This means pg_stat_activity, performance_schema.session_connect_attrs, v$session, sys.dm_exec_sessions, and engine-level audit logs all name the individual user. Auditability therefore does not depend solely on DBBat's own records — the upstream database keeps a correlatable trace of its own.
Read-Only Mode
When a grant has read_only in its controls, DBBat enforces read-only access through defense in depth.
Layer 1: Query Inspection (all engines)
Queries are inspected and blocked if they match write patterns:
- DML:
INSERT,UPDATE,DELETE,MERGE,REPLACE - DDL:
CREATE,ALTER,DROP,TRUNCATE - DCL:
GRANT,REVOKE - Other:
COPY FROM(PG),CALL(procedures),LOAD DATA,SELECT … INTO OUTFILE,SELECT … INTO DUMPFILE(MySQL)
Layer 2: Engine-level session flag
-
PostgreSQL — at connection establishment, DBBat sets:
SET SESSION default_transaction_read_only = on;PostgreSQL then blocks any write regardless of SQL syntax.
-
MySQL/MariaDB —
SET SESSION TRANSACTION READ ONLYonly applies to the next transaction and is trivially bypassable, so DBBat does not rely on it. Layer 1 (regex inspection) is the active control. Recommendation: alsoGRANT SELECTonly to the upstream MySQL user. -
Oracle — same as MySQL: regex inspection only. The defensive recommendation is to grant
CREATE SESSION+SELECTprivileges to the upstream Oracle user, nothing more.
Layer 3: Bypass Prevention (PostgreSQL)
Attempts to disable read-only mode are blocked:
SET default_transaction_read_only = offRESET default_transaction_read_onlySET SESSION AUTHORIZATION(privilege escalation)SET ROLE(privilege escalation)
Limitations
Read-only mode is defense in depth for trusted users, not a security boundary against malicious actors:
- Regex-based inspection may miss edge cases
- New SQL syntax could bypass detection
- Functions with
SECURITY DEFINERmight execute writes
For untrusted access: also restrict the upstream database user to read-only privileges.
MySQL LOCAL INFILE Defense
LOAD DATA LOCAL INFILE lets a MySQL server ask a connected client to upload an arbitrary local file. A compromised upstream server could issue this request mid-query against any client. DBBat blocks it on two layers:
- SQL regex refuses the keyword in inbound client queries.
- Capability opt-out: when the proxy connects upstream it explicitly clears
CLIENT_LOCAL_FILESfrom the negotiated capabilities. The upstream then never advertises the feature on this connection, so even a compromised server cannot request aLOCAL INFILEupload through the proxy.
Audit Trail
What's Logged
| Event Type | Data Captured |
|---|---|
| Connections | User, database, source IP, timestamps, query count |
| Queries | SQL text, parameters, execution time, rows affected, errors |
| Query Results | All result rows (for replay/audit) |
| Access Changes | Grant creation, revocation, user changes |
Audit Log Integrity
- Tamper-evident, not tamper-proof. Every
audit_logentry and every logged query carries an HMAC over its own content plus the previous record's MAC, so modifying, deleting or reordering a record is detectable — including by someone with write access to DBBat's PostgreSQL store. The chain key is HKDF-derived fromDBB_KEYand never stored in the database. Verify withdbbat audit verify [--queries], or — for scripted evidence collection — with the admin-onlyGET /api/v1/audit/verify, keeping in mind that an answer from the server is only as trustworthy as the server. See Tamper-Evident Audit Log for the scope and its limits, and Compliance for how to phrase it to an auditor. - DBBat itself only ever inserts audit rows — it never updates or deletes them —
and the REST API exposes reads only (
GET /api/v1/audit). There is no database-level append-only enforcement (no triggers, noREVOKE, no WORM storage); the chain gives you detection, not prevention. - Includes
performed_byfor accountability
API Rate Limiting
All authenticated endpoints are rate-limited:
- Per-user request limits
- Response headers indicate remaining quota
429 Too Many Requestswhen exceeded
Rate limit exempt users can be configured for automation/CI.
Network Security
Upstream Connections
DBBat supports PostgreSQL SSL modes for upstream connections:
| Mode | Description |
|---|---|
disable | No SSL |
prefer | Try SSL, fall back to plain (default) |
require | Require SSL, don't verify certificate |
verify-ca | Require SSL, verify CA |
verify-full | Require SSL, verify CA and hostname |
Recommendation: Use require or stronger for production.
Client Connections
TLS termination is built into the PostgreSQL, MySQL, MongoDB and SQL Server listeners, and each is configured the same way: DBB_<PROXY>_TLS_CERT_FILE / DBB_<PROXY>_TLS_KEY_FILE (both PEM-encoded, both or neither), with DBB_<PROXY>_TLS_DISABLE=true to keep the listener plaintext-only. If no cert and key are configured, the proxy auto-generates a self-signed certificate and an RSA-2048 key at startup — fine for development, not something to serve production traffic with. See Proxy TLS termination for every variable and its default.
- PostgreSQL listener:
DBB_PG_TLS_*. Do not disable it lightly — a client using the libpq defaultsslmode=preferfalls back to plaintext silently rather than failing. - MySQL listener:
DBB_MYSQL_TLS_*. The key must be RSA — the non-TLScaching_sha2_passwordpublic-key path needs it. - MongoDB listener:
DBB_MONGO_TLS_*. TLS is implicit from the first byte (noSTARTTLS), and SASLPLAINis only accepted over TLS. - SQL Server listener:
DBB_MSSQL_TLS_*. Disabling TLS answersENCRYPT_NOT_SUP, which also refuses clients that require encryption.DBB_MSSQL_TLS_MAX_VERSIONcaps the client-leg handshake at1.2(the default) or1.3; 1.3 is opt-in and verified againstgo-mssqldbonly. - Oracle listener: plain TNS only — no TLS termination. Deploy it behind a TLS-terminating load balancer, a VPN, or a private network.
SSH Bastions
A server can set via_uid pointing at another server whose protocol is ssh. Its upstream connection is then dialled through that SSH bastion. This works for all five proxied protocols (PostgreSQL, Oracle, MySQL/MariaDB, MongoDB, SQL Server).
Host-key trust model (TOFU)
Bastion host keys are pinned trust-on-first-use:
- The first successful connection to a bastion records the host key it presented.
- Every later connection must present that same key, or the connection is refused.
- The pinned key is exposed read-only on the server as
ssh_known_host_key, so operators can inspect and compare it out of band.
What this does protect against: a man-in-the-middle appearing after the key was pinned, and silent substitution of the bastion.
What this does not protect against: an attacker already in position on the very first connection — that key is trusted without prior knowledge. Verify ssh_known_host_key against the bastion's real fingerprint after creating the server, particularly on untrusted networks.
Private key storage
SSH private keys and their passphrases are write-only. They can be set or replaced through the API, but are never returned by any read endpoint — the same encryption-at-rest treatment as target database credentials.
Security Checklist
Deployment
- Set strong encryption key (
DBB_KEYorDBB_KEYFILE) - Use separate database for DBBat storage
- Enable TLS for upstream connections (
ssl_mode: require) - Deploy in private network or behind VPN
- Change default admin password immediately
Operations
- Use time-limited grants (hours/days, not years)
- Set query and byte quotas on all grants
- Prefer
read_only(andblock_ddl/block_copywhere useful) unless writes are required - Review audit logs regularly
- Rotate API keys periodically
- Monitor for blocked query attempts
SSH Bastions
- Verify
ssh_known_host_keyagainst the bastion's real host-key fingerprint after first connection - Use a dedicated, unprivileged SSH account for DBBat on each bastion
- Prefer key authentication over passwords; keep the private key write-only in DBBat and never re-export it
- Restrict the bastion account to port forwarding only (no shell), and to the target host/port
- Remember that SSH servers are admin-only and can never be granted to a user directly
For Target Databases
- Use a dedicated upstream user for each target (PostgreSQL, Oracle, MySQL/MariaDB, MongoDB, SQL Server)
- Grant minimum required privileges to that user
- For read-only grants, also restrict the upstream user to read-only privileges
- PostgreSQL:
GRANT SELECTonly - MySQL/MariaDB:
GRANT SELECT ON db.* TO 'dbbat_ro'@'%' - Oracle:
CREATE SESSION+SELECTprivileges only
- PostgreSQL:
- Enable engine-level audit logging as an additional layer