Explanation: Security model
Start
OverviewConceptsGuides
Run and configureREST API walkthroughCommand lineQuery groupsNotebooksRules and SRLPrefixesTestingMCP clientsPublish as toolsQuery bench in chatStatic exportETLDeployingReference
ConfigurationFeature flagsEntity modelSRL languageScriptsExplanation
ArchitectureVersioningEnvironmentsStorage and cachingSecurity modelRDF PatchInline inputsExamples
Sudoku solverSecurity model
This page describes what sqlib actually enforces, what it deliberately does not, and why the defaults are where they are. Where a protection is designed but not shipped, it says so.
For reporting a vulnerability and for the short deployment summary, see SECURITY.md.
The two authentication legs
sqlib sits between callers and triplestores, so there are two separate questions and they have separate answers.
sqlib to a triplestore. Service credentials, supplied by environment variable, resolved per backend. Shipped and in use.
A caller to sqlib. Identity and authorization. OIDC bearer token validation with per-library and per-backend grants is enforced at the routes. A backend's graph policy then scopes which of its graphs a caller may read (see Backend graph policies). A per-query ASK gate is designed and not shipped.
What SQLIB_AUTH_MODE
does
Four values. The default is
disabled.
| Mode | Token required | Effect |
|---|---|---|
disabled (default) |
no | Every request gets a synthetic full-access context. Every check exists and passes. |
dry-run |
no | A token, if presented, is verified and grants resolved; a request
that would be denied is logged as would-deny and served
anyway. |
required |
yes | A request without a valid token gets 401 (or
503 if the JWKS endpoint cannot be fetched, because that
failure is the server's, not the caller's). A token whose
kid is not in the published key set is a 401;
an unknown kid refetches the key set at most once per
30-second cooldown. |
anonymous |
no, and none is read | Every request is the one principal
urn:sqlib:principal:anonymous, with the grants on that
principal and nothing else. Enforced like required: a check
that fails is refused, a graph policy narrows, a listing hides. No
issuer is needed, nobody is an administrator, and nothing is recorded at
request time. |
anonymous is the mode for a public deployment: beside
SQLIB_READ_ONLY=true, a grant of execute on a
library and query:saved on its backends lets every visitor
run the seeded queries and refuses any SPARQL they typed themselves, on
/sparql, in a draft, or through MCP. A grant of
query:adhoc on one backend opens that backend to ad-hoc
text and no other. Grants are seeded with
SQLIB_AUTH_SEED_GRANTS or written by an administrator on a
writable instance of the same auth store. Because every caller is one
principal, SQLIB_AUTH_ALLOW_LIBRARY_CREATE=all would let
anyone create a library on a writable deployment; set it to
admin unless that is intended. The anonymous principal is
not the authenticated sentinel, so a grant to "everyone signed in" does
not reach a visitor.
Setting dry-run or required without
configuring an issuer is a startup error, not a silent fallback. So is
an issuer without an audience, unless
SQLIB_AUTH_AUDIENCE_UNCHECKED=true says it is deliberate:
with no audience the aud claim goes unchecked and a token
the issuer minted for any other application is accepted.
The design decision behind all four is that the request path is the
same shape in every mode. The auth plugin is registered as an
onRequest hook before any route, and disabled
mode decorates a full-access AuthContext rather than
skipping the decoration. Nothing downstream branches on "is auth on", so
an enforcement point cannot be accidentally absent in one mode and
present in another.
/health, / and the protected-resource
metadata (/.well-known/oauth-protected-resource and its
path-suffixed forms) are public in every mode. Every 401 names that
metadata in WWW-Authenticate: Bearer resource_metadata="…",
which is how a client finds the authorization server (RFC 9728).
/docs and the routes under it are public unless
SQLIB_AUTH_PROTECT_DOCS=true. Public routes are matched by
the route pattern Fastify registered (after APP_BASE_PATH),
not by the raw URL, so a route such as /backends/:id/health
is not public because of its last segment.
What is verified
tokenVerifier.ts validates JWTs against issuers you
configure. sqlib is a resource server only: it never issues, stores or
resets a credential. Only asymmetric algorithms are accepted
(RS*, PS*, ES256,
ES384, EdDSA); HS* is excluded so
that a leaked public key cannot mint tokens. The key set comes from
SQLIB_AUTH_JWKS_URI, or else from the issuer's OIDC
discovery document, which must name the same issuer.
Claims become principals: the subject, each value of the configured group claim, and a sentinel matching every authenticated caller.
A person is always their issuer and sub, which never
change. For display only, sqlib also keeps a label from the token:
email, else preferred_username, else
name, and the Access screen shows it in place of the
subject. These are standard OIDC claims, but an access token carries
them only when the identity provider puts them there, usually when the
client asks for the email or profile scope.
With none of them, the screen shows the subject. A label never decides
access: emails and usernames can change or be reused, so grants stay on
the subject.
What is authorized
Grants are (principal, resource, modes) over two
resource kinds. A library grants read, write,
execute, delete and control.
Execute is deliberately independent of read, so "may run the library's
saved queries" and "may read their text" are separable.
A backend grants two operations at two levels each:
| Mode | Allows |
|---|---|
query:saved |
Run saved queries (a stored version) against the backend, and read its metadata. |
query:adhoc |
Also run query text the caller writes, on /sparql or as
a draft. |
update:saved |
Run saved updates, and revert a patch. |
update:adhoc |
Also run update text the caller writes, upload data, apply a patch and clear the store. |
adhoc includes saved, so a grant of
query:adhoc satisfies a check for query:saved.
The Access screen shows the two operations as two toggles per backend:
none, saved or ad hoc.
An environment grants promote (move its version
pointers) and execute (run exactly the versions it points
to). Library modes never imply them. See environments.md.
Library grants and backend grants are separate. execute
on a library runs its saved entities, and every backend they run on
needs a backend grant of its own. A defaultBackend, on a
query or a library, is a UI convenience: it picks the backend a client
offers first, and it grants nothing. /execute never falls
back to it. A query run must name its backendId. Every
check happens in one place, GuardedSparqlExecutor, which
wraps every executor a caller's request obtains, so a saved update
reached through /execute needs update:saved
like any other.
Resolution is additive: the effective grant set is the union over all of a caller's principals. There is no ordering, no precedence and no deny rule. Composition can only widen access, which is what keeps a grant set auditable.
Enforcement is a plugin-scoped preHandler registered on
twelve entity route plugins rather than a check written into each
handler. The comment on entityGuard.ts gives the reason: a
per-handler check is only as good as the next handler somebody adds,
whereas a plugin-scoped guard covers routes that do not exist yet.
Handlers still add explicit checks where the rule is not "the entity
named in the path" — creation from a request body, execution, and
anything reaching a second entity.
Every allow, deny and would-deny is logged as an audit event with the decision, the resource, the mode and the grant that produced it. Token claims are never serialised into the log.
What is not authenticated
/mcp has no authentication of its own.
The MCP endpoint is registered on the same Fastify instance as the API,
so the auth plugin's onRequest hook does cover it: under
required, a request with no bearer token is refused before
the transport sees it, and a token that is present is forwarded onto
every app.inject call so tools run under that caller's
grants. Each MCP session is bound to the caller that created it (a
digest of the Authorization header, or of the verified
token's issuer and subject), and a request on that session from anyone
else gets 403, so a leaked session id does not hand over the session.
What does not exist is per-tool authorization, and under the default
disabled mode no token is checked at all: callers are told
apart only by whatever Authorization header they send.
packages/mcp-server/test/http-server.test.ts pins that
/mcp refuses a request with no bearer under
required.
The in-app assistant has no authorization of its own. Its route module registers no entity guard and calls no enforcement helper. Its own header states the position:
Unauthenticated, like
/mcpbeside it. That is not an oversight, it is the gap the plan names: both doors are blocked on the caller-authorization model, and until then this is a single-tenant, local-or-trusted-network feature.
The practical reading for an operator: in a default deployment, anyone who can reach the port can use both doors, and network reachability is the only access control they have. Put the server behind something that enforces your own.
The assistant does have a capability boundary of a different kind.
Its tool allowlist is read-only by construction: every catalogue tool
that mutates server state is absent rather than disabled, its writes go
through draft tools that stage into the session for a human to save, and
sparql.proxyQuery is excluded at any setting because
/sparql executes UPDATEs and that would be a write channel
to the user's triplestore around the draft gate. A list of forbidden
name patterns exists so a newly added catalogue tool cannot quietly
become assistant-reachable. execute.run is allowed, on the
argument that it runs an artifact a human already saved.
Note the interaction with prompt injection: the assistant's context is fed by user-writable entity content — query text, descriptions, results — so the allowlist is what bounds the damage an instruction hidden in that content can do.
Backend graph policies
A backend can carry a graph policy: a saved SELECT that decides, per caller, which graphs of that backend the caller may read. It is the second of the three tiers the original design named. Tier 1, a grant on the backend, decides whether a caller may run anything at all; the policy then narrows what a query reads. Tier 3, a policy graph with an ASK gate per query, is not implemented.
How a policy is set
An administrator sets two fields on the backend, together:
graphPolicyQueryVersion: a query version, pinned. Later edits to the query change nothing until the administrator pins the new version.graphPolicyBackend: the backend the policy runs on. It must be an auth backend (authBackend: true), or the library store.
Saving checks the policy once, so a policy that could never run is
refused with a 400 instead of failing every request later. The policy
must be a SELECT. It may project only ?graph,
?defaultGraph and ?namedGraph. It may declare
only the two single-variable slots
VALUES ?authUser { UNDEF } and
VALUES ?authGroup { UNDEF }, and no LIMIT or OFFSET
parameters. A backend cannot be its own policy backend, and an auth
backend cannot have a policy.
An auth backend holds access data. Only administrators may query it, through any route, so the rules that decide access are not themselves readable.
How a policy is applied
For each read, sqlib runs the policy on the auth backend with the
caller bound in: ?authUser to the caller's user principal
IRI, and ?authGroup to one row per group principal IRI the
token carried (zero rows for no groups, so a group rule matches
nothing). The result becomes a dataset:
?graphfills both the default graphs and the named graphs.?defaultGraphand?namedGraphfill one part each.- A part the policy does not project is empty: the caller reads no graphs there.
That dataset is intersected with any dataset the caller asked for
(the graph picker, defaultGraphs/namedGraphs
on /sparql and /execute) and with dataset
edges in a query group. The intersection is sent as the SPARQL protocol
parameters default-graph-uri and
named-graph-uri, or as Oxigraph's dataset options, which
override FROM and FROM NAMED in the query
text. An empty part is sent as urn:sqlib:empty-dataset, a
graph that names nothing, because no parameter would mean "the
endpoint's whole dataset".
Results are cached per backend and caller for
SQLIB_AUTH_POLICY_CACHE_SECONDS (default 60). A policy
change on the backend clears the cache, and so does any update to an
auth backend through sqlib. Any other change to the access data, such as
an edit to a data graph the auth backend is hydrated from, takes effect
within the cache time.
Who is not policed
- Administrators and sqlib itself. A caller with full
access (any caller in
disabledmode) or the administrator grant is not narrowed. Neither are sqlib's own internal queries ({ internal: true }). UseTest ason the Access screen to see what a person would read. - Updates. The policy narrows reads only. An update
names its target graphs in its own text, and the dataset parameters
scope only an update's
WHEREclause. Theupdate:*modes therefore mean "may write any graph of this backend", and theWHEREof an update is not narrowed either. Grant update modes only to callers who may read and write the whole backend. - Browser backends and exported bundles. These send queries from the browser straight to the endpoint, so sqlib never sees them.
- Data graphs. An in-process backend hydrated from
data graphs is only as private as the libraries holding those graphs:
anyone with
readon such a library reads the graph through the Data graphs routes. Keep them in a library only administrators may read. - The patch log. A patch holds what an update wrote,
in every graph. On a policed backend, reading the patch log needs
update:saved, notquery:saved.
Fail closed
In required mode every failure to evaluate a policy
refuses the request with a 503 and a generic message: an unknown
version, an unreachable auth backend, a policy that no longer passes the
checks, or a result that is not SPARQL JSON. In dry-run
mode the failure and the narrowing are logged as would-deny audit
events, and the query runs unnarrowed.
What stays the deployment's job
SERVICEis not stripped. A query that namesSERVICEon a policed backend runs, with a warning (theX-Sqlib-Warningheader on/sparql), because the dataset does not narrow the remote endpoint. On an in-process backend there is nothing to reach: Oxigraph's Node build has no service handler, so anySERVICEclause fails the query. On an HTTP backend, turn federation off in the store if the policy must hold.- sqlib must be the only path to the store. If callers can reach the SPARQL endpoint directly, the policy does not apply to them. Keep a policed store on a private network.
To try all of this on one machine, with Rauthy issuing the groups,
run just demo-graph-access (see Deploying).
The caller in saved queries
Grants decide which queries a caller may run. They cannot
decide for whom a query runs: a query that takes the caller as
a parameter trusts whatever value the caller sends. So any saved query
may declare the slots a graph policy reads,
VALUES ?authUser { UNDEF } and
VALUES ?authGroup { UNDEF }, and sqlib fills them from the
sign-in. The rules are in guides/publishing-tools.md ("The
caller").
The fill happens in completeArgumentSets
(packages/runtime), the one step that every substitution
takes, on the template path and the AST path alike. It takes a
three-valued caller option:
- an identity fills the slots;
nullmeans no one, and fills both slots with zero rows;- no option at all, for a text that declares a slot, is refused.
The third case is how the design fails closed. A route that forgets
the caller does not run the slot open, which would match every caller;
it fails. The API's own wrappers (applyExecutionArguments,
invokeCallable) take the caller as a required field, so the
compiler lists every call site.
No one is signed in for a full-access context (disabled
mode, dry-run without a valid token), for
anonymous mode, whose one principal identifies nobody, and
for sqlib's own work. A door refuses such a run (401 or
403) before it starts; the zero rows are the safety net
behind that refusal.
Ad-hoc text on POST /sparql is filled too. That gives
nothing away: a caller who may run ad-hoc text can type any IRI into
it.
Backend credentials
A Backend entity stores an authEnvKey — a
name, constrained to ^[A-Z0-9_]+$. It stores no secret. The
legacy username and password fields were
removed from the data model, and a request carrying them is rejected by
schema validation.
At execution, backendAuth.ts reads from the server's own
environment:
SQLIB_BACKEND_<KEY>_USERNAME + SQLIB_BACKEND_<KEY>_PASSWORD → Basic
SQLIB_BACKEND_<KEY>_AUTH_HEADER → used verbatim
Basic takes precedence over a header; absent both, the request goes out unauthenticated. One function builds the header for every server-side caller — the executor, the capability probe, the prefix service — because a second copy is a second place for a store to start answering 401.
Three properties follow. Exporting a library, copying its
.nq snapshot or dumping its triplestore carries no
credentials. Rotating a credential is an environment change and a
restart, with no entity edit. And anyone who can execute against a
backend is using those credentials, which is exactly why the
unauthenticated doors above matter: the library does not hold the
secret, but it holds the ability to spend it.
Two adjacent notes. The library storage backend is reserved for administrators even when a caller holds grants elsewhere, because reading it directly would bypass the entity layer.
Grants and principals are not in the library store. They live in the
urn:sqlib:auth graph of an auth store of their own: one
SQLite file by default, one N-Quads file, or a SPARQL dataset of its own
(SQLIB_AUTH_STORE_TYPE). The SQLite store also keeps an
audit trail: each change's added and removed quads, with the time, in
auth_events. A backup, restore or reset of the library
store leaves access control alone, and no export or library-store query
reaches it. Deleting a library or a backend removes the grants that name
it. And the static export bundle carries no authorization of its own: an
exported bundle grants whatever the endpoint grants its caller, so a
query whose safety depended on server-side scoping should not be
exported.
Why ETL and the assistant default off
Both defaults are capability grants, not maturity judgements. The flag comments state it in those terms.
ETL. FEATURE_ETL and
FEATURE_PLAYGROUND_ETL default false. ETL accepts arbitrary
DuckDB SQL, which is a host filesystem read primitive and, with the
httpfs extension, an outbound request primitive. That is the feature — a
pipeline that cannot express its own extraction is not an ETL tool — and
it is the risk. A capability with that blast radius should not appear
because somebody upgraded.
Underneath the flag, the DuckDB instance is configured with every capability off and locked. What actually enforces something, measured against the binding in use rather than taken from documentation:
enable_external_access=falseat instance creation blocks file reads, network reads andINSTALL, and cannot be re-enabled on a running database. This is the one hard boundary available.- Disabling autoinstall and autoload stops extensions appearing on demand.
lock_configuration=true, applied on a boot connection before any caller SQL runs, blocks every laterSET, including from another connection on the same instance. Without it, submitted SQL rewrites its own profile.
A directory allowlist is deliberately not offered, because it does
not work: allowed_directories is rejected by the binding's
instance config, and when set over SQL it is accepted and enforces
nothing — with it set and the configuration locked,
read_csv('/etc/passwd') still returns rows. A knob that
appears to confine ETL to a directory but does not is worse than no
knob, so filesystem access is all-or-nothing.
ETL_DUCKDB_ALLOW_HTTP implies filesystem access, because
DuckDB gates both on one setting. Deadlines and a memory limit are on by
default.
Under required, every route that stores or runs ETL SQL
needs administrator access rather than a library grant:
POST /etl-jobs/preview through adminSuffixes
on its route plugin, and POST /etl-jobs/:id/versions,
POST /etl-jobs/:id/execute and
POST /playground/etl/execute through an explicit
requireAdmin in the handler (a suffix would match every
method on the path, reads included). Storing a version counts, because a
stored version is SQL that a later run executes. A library grant answers
the wrong question — "may do anything to my own queries" is not "may
read the host filesystem", and the blast radius of submitted SQL is the
process and its host, not an entity. Those routes are checked by a test
that mounts the real route plugins and requires a principal holding
every library mode, and not administrator, to be refused — both a route
added without a guard and a guard deleted from a route fail it.
Under the default disabled mode there is no principal
and every request carries full access, so those checks are no-ops.
The flag, not the authorization check, is what protects a
default deployment.
No etl.* tools exist in the MCP catalogue, so neither an
MCP client nor the assistant can reach ETL at all.
The assistant. FEATURE_ASSISTANT
defaults false for three reasons stated together: it is unauthenticated,
it calls a paid third-party provider, and it can reach a configured
backend. The provider key is supplied by the caller per request
precisely so the server does not hold one.
How caller values reach SPARQL
Argument values are never concatenated into query text.
Two paths exist and both go through the same validation. The AST path
parses the query, finds the VALUES clauses whose only row
is all UNDEF, and replaces that row with term nodes built
from the supplied bindings. The template path, shared with the exported
runtime, splices serialised terms into recorded offsets in the query
text.
The security property is provided by
packages/runtime/src/sparql-terms.ts, and its own header
states the contract:
The security contract is validation, not sanitisation. Every value is either provably inside its SPARQL terminal production or rejected. We never try to make a dangerous value safe by rewriting it, except for the one production where the grammar defines an escape mechanism (string literals).
Concretely: an IRI is rejected if it contains any character
IRIREF forbids between the angle brackets
(< > " { } | ^ \ and everything below U+0021); a
language tag must match LANGTAG exactly, because language
tags have no escape mechanism; a string literal's four reserved
characters are escaped, and nothing else is touched, so the round trip
is byte-exact.
The AST path validates through the same serialiser and discards the string, so there is one definition of "is this value representable" and the two paths cannot drift into accepting different inputs.
This is a fix for a real hole rather than a precaution. The parser's
generator escapes string literals but emits IRIs and language tags
verbatim, so < + rawIri + > was enough
to add terms to a VALUES block:
{ type: 'uri', value: 'http://e/a> <http://e/b' }
→ VALUES ?l { <http://e/a> <http://e/b> } // two rows, valid SPARQL
The caller chose which rows a parameterised filter matched.
packages/compiler/test/parser.injection.test.ts pins the
fix in two ways: a table of hostile values that must be rejected, and a
property-based test asserting that every string surviving validation
yields exactly one row in the resulting query, counted by re-parsing the
output with an independent parser.
What this prevents. A supplied argument cannot terminate its own production and reach the surrounding query structure. It cannot add rows, add clauses, close a graph pattern or append an update.
What it does not prevent. It says nothing about what
the query itself is allowed to do. A saved query containing
SERVICE federates; a saved update writes.
POST /sparql takes arbitrary query text, subject only to a
backend grant. Parameter values are safe; the query that consumes them
is whatever somebody with write access to the library put there. An
update goes over POST only: GET /sparql answers an UPDATE
with 405, because any page a visitor opens can make their browser send a
GET (a link, an image, a prefetch), and CORS stops the page reading the
answer, not the request running.
A read-only deployment
SQLIB_READ_ONLY=true refuses every write to sqlib's own
state: the catalogue changes by redeployment, and anything a visitor
authors is kept in their browser. It is a separate axis from
SQLIB_AUTH_MODE and needs no principal — see configuration
for the gate and its exceptions.
It is worth stating what it is and is not, because "read-only"
invites a reading it does not support. It stops sqlib storing what a
visitor sends. It does not authenticate anybody, it does not stop a
visitor reading the whole catalogue, and it does not decide whether a
backend accepts a write — POST /sparql still
passes an update through, and the store refuses it or does not, on its
own terms. A deployment that wants its own stores immutable configures
those stores read-only; the flag is not a substitute for that.
Browser backends. The SPA lets a visitor register a
SPARQL endpoint in their own browser (localStorage), and
runs those queries from the tab rather than through sqlib. Neither the
endpoint, the query text, any header the visitor attached, nor the
results reach the server. This is a stronger position than the
inline-endpoint field it replaces, which posted the endpoint to sqlib
and made the server fetch it — on a public site, a server that fetches a
caller-supplied URL is a request forgery primitive pointed at whatever
the host can reach. It is also why POST /backends/probes is
absent from the read-only exceptions: it is that primitive by another
name.
The cost is CORS. A browser can only read an endpoint that sends
Access-Control-Allow-Origin, so an endpoint that answers
curl can still be unreachable from the page; the SPA says so
specifically rather than reporting a generic network failure.
Before you expose this
- Decide what
SQLIB_AUTH_MODEis. The default isdisabled, which means no authentication. Set an issuer and usedry-runto see what would be denied before switching torequired. - Do not expose
/mcpor the assistant to a network you do not control. Neither has authorization of its own. If the API is reachable, so are they. - If it is a demo, set
SQLIB_READ_ONLY=true. Authorization is not what keeps a public deployment's catalogue intact; refusing writes is. Underdisabledmode every visitor holds full access, so without this flag a public site is one where anybody may edit or delete anything. - Make sqlib the only path to your stores. Firewall the SPARQL endpoints to sqlib. Any bypass path voids every authorization decision sqlib makes.
- Leave
FEATURE_ETL,FEATURE_PLAYGROUND_ETLandFEATURE_ASSISTANToff unless you have decided the deployment can carry them. If you turn ETL on, grant no DuckDB capability you do not need, and confine the process with the container rather than with a directory allowlist that does not work. - Turn off the sections you are not using. A feature flag that is off means the routes are never registered, which is a stronger statement than a 403.
- Check
SQLIB_AUTH_PROTECT_DOCSandSQLIB_AUTH_ALLOW_LIBRARY_CREATE./docsis public by default, and library creation is open to any authenticated principal by default. - Keep backend credentials in the environment. Never
put a secret in an entity field. Confirm
authEnvKeynames the key you think it does. - Do not run two sqlib processes against one library
store. Each holds its own cache and neither is told when the
other writes. Back up the auth store (
SQLIB_AUTH_STORE_DIR, or its SPARQL dataset) as well as the library store: they are separate. Copy a live SQLite store withsqlite3 auth.sqlite ".backup copy.sqlite", notcp, so the copy includes its WAL. - Review what you export. A static bundle carries no authorization, and updates are not exportable — read queries only.
- List the browser origins that may call the API in
SQLIB_CORS_ORIGINS. Unset, a production deployment allows none, which is right when the SPA is served from the API's own origin. A listed origin gets credentialed access;*allows any origin without credentials. See configuration. - A hosted multi-tenant deployment cannot enable ETL on shared infrastructure. There is no setting that makes arbitrary SQL safe to offer to tenants who do not trust one another.
Related
- SECURITY.md — reporting, scope and the short posture summary.
- architecture.md — where the auth plugin sits in the request path.
- guides/etl.md — what enabling ETL grants, in full.
- guides/deploying.md — running the image.
- guides/static-export.md — what a bundle carries.
- reference/feature-flags.md — every flag and its default.
- reference/configuration.md —
every
SQLIB_AUTH_*andETL_DUCKDB_*variable.