API Authentication
The GeoLens REST API supports three authentication methods. Pick the one that matches your client:
- JWT Bearer tokens: short-lived, good for end-user apps and the web UI.
- API keys: long-lived, good for scripts, dashboards, and machine clients.
- OAuth/OIDC: admin-configured, good for SSO via Google, GitHub, Microsoft, or generic OIDC providers.
Resolution order on every request: X-Api-Key header -> ?api_key= query
parameter (reads only, from v1.18.1) -> Authorization: Bearer <jwt> header ->
anonymous. The first match
wins.
Replace https://geolens.example.com with your GeoLens instance’s URL in every
example below.
JWT bearer tokens
Section titled “JWT bearer tokens”For interactive end-user clients, exchange a username and password for a
short-lived access token plus a long-lived refresh token, then send the access
token as Authorization: Bearer <jwt> on each subsequent request.
Step 1: log in.
curl -X POST https://geolens.example.com/api/auth/login \ -H "Content-Type: application/x-www-form-urlencoded" \ -d "username=alice&password=hunter2"Response shape:
{ "access_token": "eyJhbGc...", "refresh_token": "...", "token_type": "bearer", "expires_in": 900}expires_in is the number of seconds until the access token expires — schedule
your refresh from it. refresh_token is null when the caller opted into the
browser cookie flow described below.
Step 2: use the access token.
curl https://geolens.example.com/api/collections/datasets/items \ -H "Authorization: Bearer eyJhbGc..."Refresh tokens. Access tokens are short-lived. When yours expires, exchange
the refresh token for a new pair via POST /api/auth/refresh/. Refresh tokens
rotate on every call, so store the new one and discard the old.
Where to store tokens. In the browser, keep the access token in memory
(not localStorage) and let GeoLens hold the refresh token for you: send
X-GeoLens-Auth-Mode: cookie on POST /api/auth/login and the server returns
the refresh token as an httpOnly cookie with a null refresh_token in the
body. Login also sets a second cookie, geolens_csrf, which is deliberately
not httpOnly so your script can read it. POST /api/auth/refresh/ with the
same header reads the refresh cookie and requires you to echo that value back
in an X-CSRF-Token header — the double-submit check compares the two and
answers 403 when they disagree. Programmatic callers (CLI,
SDKs, CI) omit the header and keep the body token. For server-side scripts,
treat both like any other secret: environment variables or a secrets manager,
never committed to source control.
Sign-in throttling
Section titled “Sign-in throttling”Since 1.25.0, password sign-in is limited per account as well as per address
(the per-address limit is LOGIN_RATE_LIMIT). After 10 failed sign-ins to one
account within an hour, POST /api/auth/login answers 429 with a
Retry-After header and {"detail": {"code": "login_throttled", "message": "..."}} for any client the
account does not know, even when the password is right. Existing and unknown
usernames get the same count, status and timing.
A browser that has signed in to the account before carries an HttpOnly
geolens_device_* cookie (180 days, at most five per browser, cookie auth mode
only) and keeps its own budget, so a guesser elsewhere cannot lock the owner
out. Bearer tokens, API keys and refresh are not affected. Each throttle writes
one user.login.throttled audit row per window. Shared service accounts should
use API keys instead of passwords.
To let an admin back in from a new browser, run this on the host. It prints a
single-use link, valid for 10 minutes, to a /login/trust confirmation page:
docker compose exec api /app/.venv/bin/python -m scripts.trust_login_device <username>On a multi-tenant instance add --tenant <tenant id>. The command then prints a
relative link; open it on that tenant’s own address, because the device cookie
only works on the tenant’s host.
POST /api/auth/change-password/ has its own budget of 10 failed
current-password checks per account per hour. After that it answers 429 with
Retry-After and {"detail": {"code": "password_change_throttled", "message": "..."}}, even for a correct
password. The two budgets are separate, so failed sign-ins cannot block a
password change or the reverse.
Signing out
Section titled “Signing out”POST /api/auth/logout/ revokes the account’s access and refresh tokens across
all devices. The web UI’s Sign out action uses this endpoint. API keys
remain valid; revoke them separately when needed.
Session isolation
Section titled “Session isolation”Since 1.20.0, POST /api/auth/logout/session/ revokes the refresh tokens from
one login without signing out other devices. Access tokens from that login
remain valid until their normal expiry.
Reusing a rotated refresh token after the concurrency grace period, while that token is still within its original lifetime, revokes only that login’s refresh tokens. Keep the new token after each refresh; a client affected by replay rejection must sign in again. Account-wide logout and password-reset revocation remain unchanged.
API keys
Section titled “API keys”API keys are long-lived credentials suitable for scripts, scheduled jobs, and
machine clients. They can be passed two ways. The header form is preferred.
The query-string form exists for clients that can only be handed a bare URL
and is deprecated elsewhere because a key in a URL lands in access and proxy
logs. Most tools can set the header —
ogr2ogr and ogrinfo take
--config GDAL_HTTP_HEADERS "X-Api-Key: <your-api-key>". For which credential
fits which client, and how each example sets the header (MapLibre
transformRequest, the ArcGIS JS request interceptor, QGIS’s API Header
authentication configuration, a DuckDB HTTP secret), start from the
examples repo’s authentication section
and the per-example READMEs.
Header form (preferred):
curl https://geolens.example.com/api/collections/datasets/items \ -H "X-Api-Key: <your-api-key>"Query-string form:
curl 'https://geolens.example.com/api/collections/datasets/items?api_key=<your-api-key>'From v1.18.1 the query-string form authenticates only GET, HEAD and
OPTIONS requests, minus the few GETs that write (the same ones a
read_only key is refused). On anything else, POST /api/stac/search and
POST /api/query/ included, a key in the query string is treated as absent,
so a request that relied on it answers as if unauthenticated. Move such clients
to the X-Api-Key header.
The header takes precedence over the query string. Never combine the two on the same request.
A key the server cannot resolve (revoked or mistyped) returns 401 on every endpoint that reads credentials from v1.14.0. Older releases discarded an unknown key on most routes and answered 200 with the public subset, which looks exactly like a smaller catalog, so a script whose key had lapsed kept working against less data. Requests that send no credential still get the public view.
Three cases sit outside that rule. POST /api/auth/logout/ accepts a dead
access token so a stale session can still be cleared. A request that a valid
X-Embed-Token or a valid signed tile template (sig, exp, scope) has
already authorized is served and the dead key is ignored (an invalid or
missing one puts the request back under the rule). And
GET /api/maps/shared/{token} answers 404 for an unknown link and 410 for a
revoked one whatever you send. The
API reference overview carries the full statement.
Obtaining a key. Creating a key needs a signed-in session. POST /api/auth/api-keys/ and POST /api/admin/api-keys/ answer 403 to a request that presents an API key, so a key cannot mint another key. Any signed-in user creates their own keys under
Settings > API Keys in the web UI (POST /api/auth/api-keys/) — see
Account settings -> API Keys for that form,
what the reveal dialog does, and how the list reads. Admins can
also issue and revoke keys for other users from the user-management page
(POST /api/admin/api-keys/); see
User management & RBAC. Each key belongs to exactly one
user account, and what it can do is that user’s permissions narrowed by the
key’s own scope.
Key scope. Every key is minted full or read_only, or, for a key bound to
one dataset, dataset_write or dataset_append (see below). The scope field on
the create request sets it and defaults to "full"; both mint forms in the UI
offer the same Full access / Read only picker, also defaulting to full.
A full key acts as the owner, with exactly the owner’s permissions. A
read_only key authenticates GET, HEAD, and OPTIONS only. Two POST
routes are carve-outs because both are reads that happen to be POSTs:
POST /api/query/ (the read-only SQL sandbox) and POST /api/stac/search. One
GET is refused despite its method — GET /api/datasets/{id}/validate/?refresh=true,
which recomputes and persists a quality score; the same route without refresh,
or with a false-y value, serves the cached result normally. Scope is fixed at
creation, so changing it means minting a new key and revoking the old one. Pick
read_only for dashboards, CI checks, and anything that only ever fetches.
Anything else a read_only key asks for is refused while the key is being
resolved, before the handler runs, with 403 and an RFC 7807 body:
{ "type": "about:blank", "title": "Forbidden", "status": 403, "detail": "This API key is read-only"}Dataset keys. From v1.26.0, an editor can mint a key bound to
one dataset. A dataset_write key reads and writes that dataset’s features and
nothing else. A dataset_append key can only create features, so a leaked key
cannot read or change data, which suits webhooks and IoT feeds. Both must
expire within 365 days, act as the user who minted them, and write even when
Enable Dataset Editing is off. Anywhere else they are answered as an
invalid key. Mint them with POST /api/datasets/{dataset_id}/api-keys. See
Write features through the API.
Expiry. expires_at is optional on full and read-only keys. From v1.26.0
the create forms in Settings and on the admin user page offer
Never (the default), 30 days, 90 days and 1 year, and each key in the list
shows when it expires. A key past its expiry is marked Expired and no longer
authenticates.
Security. API keys do not expire unless created with an expiry, but they are invalidated by security events on the owner’s account: a password change, a role change, a conversion between auth providers, or an admin approving a pending account all stop every existing key for that user from resolving. Signing out of the web UI deliberately does not. Rotate keys on a schedule and revoke any key whose host environment changes. Treat a leaked API key the same as a leaked password.
OAuth / OIDC
Section titled “OAuth / OIDC”GeoLens supports OAuth/OIDC sign-in via Google, GitHub, Microsoft Entra ID, and any generic OIDC provider, configured by an administrator. (GitHub is plain OAuth 2.0 rather than OIDC; OAuth/OIDC setup covers the difference.) End users do not call the OAuth endpoints directly; they sign in through the GeoLens web UI, which performs the standard authorization-code flow on their behalf.
The browser flow:
- User clicks “Sign in with
<provider>” in the GeoLens UI. - UI redirects to
https://geolens.example.com/api/auth/oauth/<provider>/login. - Provider authenticates the user and calls back to
https://geolens.example.com/api/auth/oauth/<provider>/callback. - GeoLens issues a JWT and the UI stores it.
Once the UI has a JWT, machine clients reuse it like any other Bearer token:
curl https://geolens.example.com/api/collections/datasets/items \ -H "Authorization: Bearer eyJhbGc..."Provider setup. OIDC client registration and the list of supported providers are admin-configured. See OAuth/OIDC setup.
Out of scope. GeoLens does not currently support OIDC client-credentials or PKCE machine flows. Machine-to-machine clients should use API keys.
Browser clients and CORS
Section titled “Browser clients and CORS”Anonymous requests to the standards routes and, from v1.14.1, to the anonymous
catalog-search routes GET /api/search/datasets/ and GET /api/search/facets/
get Access-Control-Allow-Origin: *, so a page on another origin can read the
public catalog with no setup. As soon as one of those requests carries a
credential (X-Api-Key, Authorization, a cookie, or ?api_key=) the wildcard
is gone: the page’s exact origin has to be listed in CORS_ALLOWED_ORIGINS, and
a literal * there is rejected. See
Settings -> Network.
Tile routes work differently. Served vector and cluster tiles (200 and 204)
and tile 429s answer with a static Access-Control-Allow-Origin: *,
credential or not, and raster tiles behind the bundled nginx send it on every
status, because a tile URL template carries its authorization in the query
string (sig, exp, scope, or the deprecated ?api_key=) and the response is
cacheable. A cross-origin page can therefore draw a signed private tile with no
CORS_ALLOWED_ORIGINS entry — but a tile fetch that sets X-Api-Key as a
header is preflighted and does still need one. Vector and cluster tile errors
(401, 403, 404) carry no wildcard, so a page on an unlisted origin cannot
read their status.
Raster tiles began sending CORS headers in v1.13.0. That matters for loaders
that fetch tiles with WebGL or fetch (MapLibre, the
ArcGIS Maps SDK for JavaScript, OpenLayers with crossOrigin set), which
draw an empty map against an older instance while the server returns valid
PNGs. <img>-based loaders (Leaflet, OpenLayers by default) render the same
tiles either way.