Authentication
A client authenticates once, at the WebSocket upgrade, with a bearer token. The
question is where that token comes from. Putting it in the ATTACH statement
works and is the worst option — it lands in your SQL history, your logs, and
anything that echoes the statement back.
Secrets
DuckDB has a secret manager. Store the token there and the ATTACH carries no
credential at all:
CREATE SECRET n6kdemo (
TYPE n6k,
TOKEN 'demo-token',
SCOPE 'n6kd-demo-106595059298.us-central1.run.app'
);
ATTACH 'n6ks://n6kd-demo-106595059298.us-central1.run.app' AS demo (TYPE n6k);
duckdb_secrets() redacts the value, so the token does not reappear in
introspection.
Scope
SCOPE is matched as a literal prefix of the URL with the scheme stripped
(host[:port][/prefix]):
| SCOPE | Matches |
|---|---|
'data.example' | that host, any port, any path |
'data.example:443' | that host on 443 only |
'data.example:443/reports' | one mount point on that host |
| omitted | any host — a catch-all |
Because the scheme is stripped on both sides, a bare hostname matches whether
you attached with n6k://, n6ks://, or host/port options.
Rotation
Replace the secret; the next attach and every subsequent request use the new value. Nothing is re-declared and nothing is restarted:
CREATE OR REPLACE SECRET n6kdemo (
TYPE n6k, TOKEN 'the-new-token', SCOPE 'data.example'
);
Precedence
- An inline
token '…'option on theATTACH - A matching
TYPE n6ksecret - A
TYPE n6k_refreshsecret, which mints one via OAuth (native only) - Otherwise no header is sent — the connection is anonymous
Anonymous is a real mode, not just a failure state. A local or test server that needs no auth is attached with:
ATTACH 'n6k://localhost:8080' AS dev (TYPE n6k, anonymous true);
anonymous true skips steps 2 and 3 outright, which also stops a stale
n6k_refresh secret from breaking an attach that never needed credentials — an
unreachable issuer would otherwise fail the connect during OAuth discovery. It
is mutually exclusive with an inline token.
OAuth
For deployments with a real identity provider, the client can hold a durable credential and mint short-lived data-service JWTs on demand. There are no n6k-specific endpoints involved; the authorization server only has to implement the standards:
| RFC | Used for |
|---|---|
| 8414 | Discovery (/.well-known/oauth-authorization-server) |
| 8628 | Device login |
| 6749 | Token endpoint |
| 8693 | Token exchange — durable subject token → data-service JWT |
| 7519 / 7517 / 7518 / 8037 | JWT, published key set, EdDSA |
n6k_login runs the device flow, prints a verification URL, waits for you to
approve it, and stores the resulting n6k_refresh secret:
SELECT * FROM n6k_login('https://auth.example', resource := 'data-service');
ATTACH 'n6ks://data.example' AS db (TYPE n6k); -- mints and renews automatically
From then on, when a request needs a token and none is cached — or the cached JWT is within about a minute of expiry — the extension performs discovery, does an RFC 8693 exchange, and caches the minted JWT as a temporary secret. The exchange is single-flight per host, so a burst of queries triggers one mint.
n6k_login and the n6k_refresh mint are native-only and throw in the browser,
where the host application owns the OAuth lifecycle and supplies the access
secret directly.
What auth does not do
Auth answers who is connecting. It does not answer what they may touch —
that is the server's permission layer, and it is a separate file with a separate
lifecycle. A valid token still gets Table 'audit_log' is read-only if the
catalog says so. See permissions.
One more limit worth knowing: the token is validated once, at the upgrade handshake, and never re-checked on a live connection. Revoking a token stops the next attach, not the session already running.