Security
This guide documents security behavior currently implemented in Adapt.
Authentication
Adapt authenticates with:
- Session cookie (
adapt_session) after a local password login or Keycloak SSO - API key (
X-API-Key) - Bearer JWT (
Authorization: Bearer) when Keycloak OIDC is configured - Local username and password at
POST /auth/login, unlessoidc.local_loginis false
Login and logout routes:
GET /auth/loginPOST /auth/loginGET /auth/oidc/loginandGET /auth/oidc/callbackwhen OIDC is onPOST /auth/logout
Session behavior:
- Session TTL is 7 days
- Sliding renewal on valid session usage
- Expired sessions are cleaned by a background task
API key behavior:
- Keys are stored as SHA-256 hashes
- Keys can be inactive or expired
last_used_atis updated on successful key usage
All authentication methods require an active user. Inactive users cannot log in or authenticate with an existing session or API key.
An administrator can deactivate a user in the Admin UI, API, or CLI. Deactivation revokes browser sessions for the user. API keys remain stored but cannot authenticate until an administrator activates the user.
The MCP interface (/mcp/, see the MCP Guide) uses the same
authentication resolver as HTTP routes. Tool calls accept a session cookie,
an API key, or a Bearer JWT. API keys remain the simple option for scripts.
OAuth MCP clients (Cursor, Claude, VS Code) use Bearer tokens. When OIDC is
configured, an unauthenticated HTTP request to /mcp/ returns 401 with a
WWW-Authenticate header that points at Adapt's protected-resource metadata.
Adapt still checks authentication when a tool executes. Cookie-authenticated
MCP requests are still subject to CSRF validation because the transport uses
HTTP POST.
Keycloak OIDC
OIDC is off until both oidc.issuer and oidc.client_id are set (or the
matching ADAPT_OIDC_* environment variables). The client secret must come
from ADAPT_OIDC_CLIENT_SECRET, not conf.json.
Adapt is a relying party for the browser (authorization code and PKCE) and a resource server for REST and MCP (JWT Bearer). It does not register OAuth clients and it does not proxy Keycloak discovery. Keycloak remains the authorization server.
On each successful OIDC login and each valid Bearer token, Adapt:
- Uses
preferred_username(oroidc.username_claim) as the Adapt username - Creates the user if needed, with a password hash that cannot log in locally
- Rejects inactive users
- Maps Keycloak
groups(path prefix stripped) andrealm_access.rolesonto existing Adapt groups of the same name - Sets
is_superuserwhen a configured role is present (defaultadapt-admin) - Removes only group memberships that OIDC previously added (
oidc_managed)
Unknown Keycloak group names are ignored. Run adapt admin create-permissions
so Keycloak groups can match <resource>_readonly and <resource>_readwrite.
Do not expect Adapt to create groups from Keycloak.
Operator checklist for Keycloak 26.6 or later:
- Confidential client
adapt-web: authorization code and PKCE, redirect{public_url}/auth/oidc/callback, logout redirect{public_url}/auth/login - Audience mapper so access tokens carry
audequal to Adaptpublic_url(or RFC 8707RESOURCE_INDICATORon Keycloak 26.8+) - Group membership mapper onto the
groupsclaim - Realm role
adapt-adminfor Adapt superusers - Realm dynamic client registration (DCR) enabled so MCP clients can register themselves with Keycloak
- Groups named to match Adapt groups (
products_readonly, and so on)
Authorization
Adapt enforces resource permissions through users, groups, and permissions.
- Superusers bypass standard permission checks
- Generated resource routes are mounted with permission dependencies
readis required for GETwriteis required for POST/PATCH/DELETE
Password Security
Password handling:
- PBKDF2-HMAC-SHA256
- 100,000 iterations
- Per-user random salt
- Constant-time comparison for verification
Users can change their password in the Profile UI. They must enter their current password. The new password must pass the password-strength check.
Administrators can reset a user password in the Admin UI, admin API, or CLI. Each password change revokes all browser sessions for that user. API keys stay active and must be revoked separately.
CSRF Protection
CSRF is enforced for unsafe HTTP methods when session cookies are involved.
Key points:
- CSRF cookie name:
adapt_csrf - CSRF header name:
X-CSRF-Token - Form field fallback:
csrf_token - API-key-only requests without session cookies are exempt
- Bearer-only requests without session cookies are exempt
- If a session cookie is present, CSRF still applies even when an API key or Bearer token is also sent
The /docs Swagger UI reads the adapt_csrf cookie and sends it as
X-CSRF-Token on Try it out requests. Cookie login (password or Keycloak)
can mutate resources from API Docs. curl and other clients still have to
set the header themselves.
For example, log in and store the session and CSRF cookies in a curl cookie jar. Then copy the CSRF cookie into the header for an unsafe request:
curl -c /tmp/adapt-cookies.txt -X POST \
--data-urlencode "username=admin" \
--data-urlencode "password=<password>" \
http://localhost:8000/auth/login
CSRF_TOKEN=$(awk '$6 == "adapt_csrf" {print $7}' /tmp/adapt-cookies.txt)
curl -b /tmp/adapt-cookies.txt -X POST \
-H "X-CSRF-Token: $CSRF_TOKEN" \
-H "Content-Type: application/json" \
http://localhost:8000/api/products/ \
-d '{"action":"create","data":[{"name":"Keyboard"}]}'
For command-line mutations, an API-key-only request is simpler and does not require CSRF handling.
Security Headers
Adapt sets security headers on responses:
X-Content-Type-Options: nosniffX-Frame-Options: DENYReferrer-Policy: strict-origin-when-cross-originPermissions-Policy: camera=(), microphone=(), geolocation=()Content-Security-Policy: ...(policy configured in code)Strict-Transport-Securitywhen TLS is enabled
Host Header Protection
Adapt uses TrustedHostMiddleware. The allowed hosts come from the configured host.
TLS and Cookies
When the configuration sets a TLS certificate and key together:
- HTTPS is active
- HSTS is active
- The server configuration turns on secure-cookie behavior
Locking and Safe Writes
Dataset write paths use lock management and atomic replacement to reduce corruption and race risks.
Lock behavior includes:
- Per-resource lock records
- Retry with exponential backoff
- Timeout-based acquisition failure
- Stale lock cleanup
- Atomic target replacement for built-in dataset plugins where the platform supports it
These mechanisms reduce concurrency and partial-write risks. They do not make
writes uninterruptible or remove every race. Adapt returns 409 Conflict when
lock acquisition exhausts all retries.
Row-Level Filtering
Plugin.filter_for_user() is an extension point that dataset reads use. The
built-in plugins do not apply per-user row filters. Dataset writes read and
rewrite row collections. This process does not safely enforce write-level
row security. Plugins must not treat this hook as authorization
for row-level mutations.
See Known Limitations.
Audit and Admin Security Endpoints
Superuser endpoints include:
/admin/users/admin/groups/admin/permissions/admin/locks/admin/cache/admin/api-keys/admin/audit-logs
Adapt records these events:
- Successful login and logout
- API-key creation and revocation
- User and group creation or deletion
- User activation and deactivation
- Group membership changes
- Permission creation, deletion, and assignment changes
- Manual lock operations
- Cache deletion and clearing
- Successful dataset creation, update, and deletion operations
REST and MCP dataset mutations use the shared audit path. The record contains the user, source IP, dataset path, action, timestamp, and mutation summary. The record does not contain dataset values.
Audit records describe successful operations. If you need a history that includes failed requests, use trusted reverse-proxy access logs.
File uploads
Uploads are off by default (upload.enabled / ADAPT_UPLOAD_ENABLED).
POST /api/uploads is the only write path that creates a new file in the
document root from an HTTP client.
Authorization:
- The caller must authenticate.
- The caller must have
writeon the document root (""). Superusers have this permission. Other users need an explicit grant. - Read-only mode (
--readonly) rejects uploads with405.
Path traversal:
- The
filenamefield must be a single basename. Slashes, backslashes, absolute paths,..segments, hidden names, and directories are rejected. - The resolved target must stay in the document root. Symlinks that escape the root are rejected.
Size limits:
- The default limit is 10 MiB (
upload.max_size_bytes/ADAPT_UPLOAD_MAX_SIZE_BYTES). - The handler counts bytes while it writes. A payload over the limit
returns
413.
Content type and MIME confusion:
- Extension allow and deny lists apply first (
allowed_extensions,denied_extensions). - Strict MIME sniffing is off by default. When
upload.strict_mime_sniffingis true, Adapt sniffs the first 4096 bytes and compares that type with the filename extension and the providedContent-Type. A mismatch returns400. - Optional
allowed_mime_typesapplies only when strict sniffing is on.
Collision:
- Default policy is
overwrite. Setcollision_policytorejectto return409when the file already exists.
Successful and denied uploads write audit records (upload_success,
upload_denied, upload_failed). A new file also gets an owner group
(upload_owner_<filename>) with read and write on the discovered resource.
Practical Checks
# Current user
curl -H "X-API-Key: <key>" http://localhost:8000/auth/me
# Audit logs (superuser)
curl -H "X-API-Key: <superuser-key>" http://localhost:8000/admin/audit-logs
# Health
curl http://localhost:8000/health
Recommendations
- Always use TLS in non-local environments.
- Rotate API keys.
- Deactivate unused API keys.
- Keep superuser accounts limited and monitored.
- Review audit logs regularly.
Manual navigation: Previous: Admin Guide | Index | Next: MCP Guide