Skip to content
v0.5.1GitHub

Operating

Security

applies to v0.5.1

With authentication on, which is the packaged default, LuraDB checks an API key on every request; GET /health is the exception. This page covers API keys, admin accounts, permissions per domain, key rotation, trusted Unix users, TLS and CORS.

A client sends its key as a Bearer token in the Authorization header. The scheme is written Bearer, followed by one space. A request without a valid key answers 401; a valid key without the right for the request answers 403.

Terminal window
curl -si http://127.0.0.1:3000/store-api/kv/default/keys/hello
curl -s http://127.0.0.1:3000/store-api/auth/whoami -H "Authorization: Bearer $KEY"
HTTP/1.1 401 Unauthorized
content-type: text/plain; charset=utf-8
Unauthorized
{ "name": "admin", "role": "Admin" }

A removed, rotated or never issued key is the same as no key: 401. There are two kinds of accounts. An admin passes every check and is the only kind that can create domains and users, read metrics and the effective configuration, and manage backups. A user reaches only the domains it has a permission on, see Users and permissions.

Request What it needs Without it
GET /health Nothing.
GET / Nothing. The route exists only while server.hello_enabled is on; the packaged file turns it off, the built-in default turns it on.
GET /version, GET /store-api/auth/whoami Any valid key. 401
The Swagger UI and /api-docs/openapi.json, when server.swagger_enabled is on Any valid key, for the page of the UI as well. 401
GET /store-api/domains, /store-api/json/domains, /store-api/rel/domains Any valid key. A user sees only the domains it has a permission on. 401
Requests under /store-api/kv/{domain}, /json/{domain}, /rel/{domain} A permission on that engine and domain. 403
Everything else under /store-api: creating and deleting domains, users, backups, metrics, configuration, events, logs An admin key. 403

The packaged file sets auth.enabled to true. The built-in default is false, which is accepted only on a loopback address: with another bind address the start is refused. With authentication off, every request is accepted and whoami answers {"name":null,"role":"Disabled"}.

LuraDB stores only the SHA-256 hash of a user key. A user key looks like lura_ followed by 64 hexadecimal digits, and the server shows it once, in the response that creates or rotates it.

Admins are not created through the API. Each [[auth.admins]] block of luradb.toml is one admin account:

[[auth.admins]]
name = "admin"
api_key = "lura_…"

At every start the server makes the stored admins equal to this list: it creates or updates each listed admin from its key and removes every stored admin that is not listed. The log says what happened:

INFO luradb::auth: Auth: removed stale admin 'deploy'
INFO luradb::auth: Auth: admin 'admin' reconciled

The configuration is read once at start, so a change of an admin key or a new admin takes effect with a restart. After the restart with a new auth.admins.api_key, the old key answers 401 and the new one is accepted:

Terminal window
curl -si http://127.0.0.1:3000/store-api/auth/whoami -H "Authorization: Bearer $OLD_KEY"
curl -s http://127.0.0.1:3000/store-api/auth/whoami -H "Authorization: Bearer $NEW_KEY"
HTTP/1.1 401 Unauthorized
{ "name": "admin", "role": "Admin" }

A second block with another auth.admins.name adds a second admin. It appears in GET /store-api/auth/users with "role": "Admin", and removing the block removes the account and its key at the next start.

The key is stored in clear text in the configuration file, which is why the package installs it with mode 640 and the owner root:luradb. GET /store-api/config shows the name of each admin and leaves the key out. With authentication on and no admin listed, the server starts and logs Auth is enabled but no admins are configured in luradb.toml. Nobody can then act as admin: only users created earlier and trusted Unix users get through.

Users are created through the API and carry the permissions an admin gives them. A user name is 1 to 50 characters of a-z, A-Z, 0-9, _ and -.

Terminal window
curl -s -X POST http://127.0.0.1:3000/store-api/auth/users \
-H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
-d '{"name": "reporter"}'
{ "name": "reporter", "role": "User", "api_key": "lura_b5e320eb…" }

A name that exists answers 409 Conflict: user already exists, a name outside the rule answers 400. A new user has no permissions: its requests to a domain answer 403. A permission names a domain, a level and the engine the domain belongs to. The engine is kv (the default when store_type is left out), json or rel; a permission on the JSON domain orders says nothing about the key-value or relational domain of that name.

Terminal window
curl -s -X POST http://127.0.0.1:3000/store-api/auth/users/reporter/permissions \
-H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
-d '{"domain": "sessions", "access": "read"}'
curl -s -X POST http://127.0.0.1:3000/store-api/auth/users/reporter/permissions \
-H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
-d '{"domain": "orders", "access": "write", "store_type": "json"}'

Both calls answer 200 with an empty body. Setting a permission again replaces the level. The levels build on each other:

Level Allows
read Reading: GET, JSON search, SQL SELECT.
write Also writing: PUT, POST, PATCH and DELETE on keys, documents, rows, bulk loads and indexes, and the SQL statements INSERT, UPDATE and DELETE.
ddl Also the SQL statements CREATE, ALTER and DROP. For key-value and JSON domains it is the same as write.

The domain must exist and be active, or the call answers 404 Not Found: domain not found. With ?allow_missing=true the check is skipped, which lets you grant a permission before the domain is created. An access outside the three levels answers 400.

GET /store-api/auth/users lists every account, admins included, with the permissions of each. API keys are never in the list.

Terminal window
curl -s http://127.0.0.1:3000/store-api/auth/users -H "Authorization: Bearer $KEY"
[
{ "name": "admin", "role": "Admin", "created_at": 1793742965, "permissions": [] },
{
"name": "reporter",
"role": "User",
"created_at": 1793742965,
"permissions": [
{ "domain": "orders", "store_type": "json", "access": "write" },
{ "domain": "sessions", "store_type": "kv", "access": "read" }
]
}
]

The permissions of an admin do not restrict it; an admin passes every check, whatever its list says. With the key of reporter, requests end like this:

Request Answer
GET /store-api/kv/sessions/keys/s-4f2a 200
PUT /store-api/kv/sessions/keys/s-4f2a 403: the level is read.
GET /store-api/kv/default/keys/hello 403: no permission on default.
POST /store-api/json/orders/documents 201
POST /store-api/rel/orders/sql with a SELECT 403: the permission is on the JSON domain, not on the relational one.
GET /store-api/domains 200, listing only sessions.
GET /store-api/auth/users, GET /store-api/metrics 403: admin only.
POST /store-api/domains 403: creating a domain is admin only.
GET /version 200

A relational link to a key or document needs read permission on the target domain too, see Domains and engines.

To take a permission away, delete it. A permission on a JSON or relational domain needs store_type in the query. The next request of the user answers 403:

Terminal window
curl -si -X DELETE http://127.0.0.1:3000/store-api/auth/users/reporter/permissions/sessions \
-H "Authorization: Bearer $KEY"
curl -si -X DELETE "http://127.0.0.1:3000/store-api/auth/users/reporter/permissions/orders?store_type=json" \
-H "Authorization: Bearer $KEY"
HTTP/1.1 204 No Content

Deleting a user with DELETE /store-api/auth/users/{name} removes the account and all its permissions, and its key stops working at once. Permissions are kept as stored: deleting a domain does not remove the permissions on it, and a domain created later under the same name finds them again.

A user key is rotated through the API. The new key is returned once, and the old key stops working in the same moment:

Terminal window
curl -s -X POST http://127.0.0.1:3000/store-api/auth/users/reporter/rotate-key \
-H "Authorization: Bearer $KEY"
{ "name": "reporter", "api_key": "lura_4d7f0eeb…" }
HTTP/1.1 401 Unauthorized

The last block is the answer to the next request with the old key: 401. An admin key is rotated in luradb.toml, as described above, and takes effect with a restart. If you suspect that a key leaked, rotate it first and look for what it did afterwards.

Clients that signed in to the MCP listener with OAuth hold tokens that are tied to the key they signed in with. When that key is rotated, their tokens stop working and they have to sign in again, see MCP server.

A local client can reach LuraDB over a Unix domain socket instead of TCP. The socket is off until server.unix_socket_path is set; the packaged file sets it to /run/luradb/luradb.sock, a directory that systemd creates for the service, see systemd and Docker. The socket file has the fixed mode 0660, so only its owner and the members of its group can connect. The service runs as luradb, and a local user joins the group with:

Terminal window
sudo usermod -aG luradb <username>

The user has to log in again for the membership to take effect. Connecting is not authenticating: a client on the socket still sends a key, and without one it gets 401.

Terminal window
curl -si --unix-socket /run/luradb/luradb.sock http://localhost/store-api/auth/whoami
HTTP/1.1 401 Unauthorized
content-type: text/plain; charset=utf-8
Unauthorized

auth.trusted_uids lists Unix user IDs that need no key. The kernel tells the server the user ID of the process at the other end of the socket, and a process whose ID is on the list is an admin for as long as the connection lasts:

[auth]
trusted_uids = [1000]
Terminal window
curl -s --unix-socket /run/luradb/luradb.sock http://localhost/store-api/auth/whoami
curl -s --unix-socket /run/luradb/luradb.sock -X PUT http://localhost/store-api/kv/default/keys/from-socket -d hello
curl -si http://127.0.0.1:3000/store-api/kv/default/keys/from-socket
{ "name": null, "role": "TrustedPeer" }

The PUT answers 200 without a key. The same read over TCP, the last request, still answers 401: trust applies to the socket only. The list is empty by default. Put only the users there whose processes you would give the admin key anyway, because a trusted process can create users, delete domains and read every key. The setting has no effect with auth.enabled off. The shared-memory read path (shm.enabled) registers its clients on a Unix socket of its own and applies the same list.

LuraDB can end TLS itself on a port of its own, next to the plain HTTP port. This is the quickstart of the package with a self-signed certificate. Create the certificate in the directory the package prepared, which only root and the group luradb can enter:

Terminal window
sudo openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 \
-keyout /etc/luradb/tls/server.key -out /etc/luradb/tls/server.crt \
-days 365 -nodes -subj "/CN=localhost" \
-addext "subjectAltName=DNS:localhost,IP:127.0.0.1" \
-addext "basicConstraints=critical,CA:FALSE"
sudo chown root:luradb /etc/luradb/tls/server.key
sudo chmod 640 /etc/luradb/tls/server.key

LuraDB does not check the permissions of the key file; keeping it private is up to you. Turn the listener on in luradb.toml and restart:

[server]
tls_enabled = true
Terminal window
sudo systemctl restart luradb
curl --cacert /etc/luradb/tls/server.crt https://127.0.0.1:3443/health
curl -k https://127.0.0.1:3443/health

The log names the new listener next to the plain one:

INFO luradb::server: Listening on https://127.0.0.1:3443
INFO luradb::server: Listening on http://127.0.0.1:3000

The first curl pins the certificate and answers like the plain port. Without --cacert a self-signed certificate is not trusted and curl ends with exit code 60; -k skips the check, which is acceptable for a test and nothing else. The request over TLS is served by the same router, so keys, permissions and CORS behave as on the plain port, and curl --http2 negotiates HTTP/2.

  • Ports. The HTTPS port is server.tls_port (3443) and must differ from server.port. To serve only HTTPS, set server.http_enabled to false; at least one of the two must stay on.
  • Files. server.tls_cert_path is a PEM file with the certificate and, if you have it, the chain after it. server.tls_key_path is a PEM private key in PKCS#8, RSA or EC form. A file that cannot be opened or parsed stops the start and the message names the key and the path.
  • Protocol. The listener offers h2 and http/1.1 through ALPN and asks for no client certificate. A connection that has not finished the handshake after 10 seconds is dropped.
  • Renewal. Certificate and key are read at start. A renewed certificate takes effect with a restart; there is no reload.
  • MCP. With TLS on, the MCP listener serves HTTPS with the same certificate.
  • Public certificates. For a certificate from a public or internal CA, for example one issued by certbot, point the two path keys at those files. TLS can also end at a reverse proxy in front of LuraDB, which then keeps its default loopback address.

A browser blocks the answer to a request that goes to another origin unless the response carries Access-Control-* headers. CORS is off. Turn it on with the exact origins your pages are served from:

[cors]
enabled = true
allowed_origins = ["https://console.example.com", "http://localhost:5173"]

Each entry is scheme://host[:port], in lower case, without path, query or user information. The single entry "*" allows every origin; mixing it with other entries is refused at start, as is cors.enabled at true with an empty list. The start log warns about "*". It does not weaken authentication: every request still needs a valid key, it only drops the origin check as an extra line of defence. Methods, headers and cache time are fixed by the server and not configurable.

The CORS layer runs before authentication, so a preflight request needs no key. A preflight from an allowed origin gets the allow headers; from any other origin the same headers come back without access-control-allow-origin, and the browser refuses the real request:

Terminal window
curl -si -X OPTIONS http://127.0.0.1:3000/store-api/kv/default/keys/hello \
-H "Origin: https://console.example.com" \
-H "Access-Control-Request-Method: PUT" \
-H "Access-Control-Request-Headers: authorization,if-match"
HTTP/1.1 200 OK
access-control-allow-origin: https://console.example.com
vary: origin, access-control-request-method, access-control-request-headers
access-control-allow-methods: GET,HEAD,POST,PUT,PATCH,DELETE
access-control-allow-headers: authorization,content-type,if-match,if-none-match,last-event-id
access-control-max-age: 3600
content-length: 0

A request from an allowed origin gets access-control-allow-origin on its answer, and the answer exposes etag and x-expires-at to the page, which a browser hides from scripts otherwise:

Terminal window
curl -si http://127.0.0.1:3000/store-api/kv/default/keys/hello \
-H "Origin: https://console.example.com" -H "Authorization: Bearer $KEY"
HTTP/1.1 404 Not Found
vary: origin, access-control-request-method, access-control-request-headers
access-control-allow-origin: https://console.example.com
access-control-expose-headers: etag,x-expires-at

When a proxy sits in front of LuraDB, every connection comes from the proxy’s address. proxy.trusted_proxies lists the proxies, as addresses or CIDR ranges, whose forwarded-address headers LuraDB believes. For a request from a listed address, the server takes the client address from X-Real-IP, and failing that from the rightmost public address of X-Forwarded-For; private, loopback and link-local addresses in that header are skipped. For any other peer the headers are ignored and the connection address counts. The packaged file lists 127.0.0.1 and ::1; the built-in default is an empty list.

At the debug level, with log.level set to "verbose", each request logs both addresses:

DEBUG luradb::api::middleware: request client_ip=203.0.113.9 peer_ip=127.0.0.1
DEBUG luradb::api::middleware: request client_ip=198.51.100.4 peer_ip=127.0.0.1

These lines come from a request with X-Forwarded-For: 203.0.113.9 and one with X-Real-IP: 198.51.100.4. In v0.5.1 the resolved address is used for the counters of the MCP sign-in page, failed sign-ins and client registrations per address, and for this log line. The rate limits of the REST API count per domain and not per client, see Troubleshooting.