Security
About 1409 wordsAbout 5 min
Security in Spora
Credential Encryption
LLM API keys and other secrets are encrypted at rest using libsodium secretbox (XSalsa20 stream cipher + Poly1305 MAC), authenticated encryption with a 32-byte master key (SODIUM_CRYPTO_SECRETBOX_KEYBYTES). The implementation lives in app/Core/SecurityManager.php (encrypt() / decrypt()).
The master key is resolved, in order, from:
SPORA_SECRET_KEYenv var — must be base64-encoded 32 raw bytesSPORA_KEY_PATHenv var — path to a 32-byte binary key fileconfig['key_path'](auto-set tostorage/secret.keyon first run ofspora:installordb:seed)
See app/Core/ContainerDefinitions.php:237-267 for the resolution chain.
Protect this key — anyone with access can decrypt all stored credentials.
API Authentication
Session-based authentication via delight-im/auth (Delight\Auth\Auth). The session is PHP’s native session, started by Symfony’s Request; the cookie name follows PHP defaults and is not set explicitly by Spora.
State-changing requests (POST/PUT/PATCH/DELETE) must include a CSRF token in the X-CSRF-Token request header. The token is generated by Spora\Security\CsrfTokenService (app/Security/CsrfTokenService.php), stored in the csrf_token session key, and issued to the client as the data.csrf_token field of POST /api/v1/auth/login and GET /api/v1/auth/me responses. The middleware that enforces it is Spora\Http\Middleware\CsrfMiddleware (app/Http/Middleware/CsrfMiddleware.php:30).
| Method | Endpoint | Description |
|---|---|---|
| POST | /api/v1/auth/login | Authenticate (issues data.csrf_token) |
| POST | /api/v1/auth/logout | End session (requires X-CSRF-Token) |
| GET | /api/v1/auth/me | Current user (returns data.csrf_token) |
Additional auth endpoints (registration, password change, email verification, etc.) are listed in the API reference and registered in app/Core/routes.php:44-55.
CSRF-exempt endpoints — the following auth endpoints are exempt from the CSRF check, because they authorize via in-URL credentials (selector/token) or run before a session is established: /auth/register, /auth/forgot-password, /auth/reset-password, /auth/email/confirm, /auth/verification/resend, /auth/verify/{selector}. /api/health and /api/v1/config are also unauthenticated.
/auth/verify/{selector} is also state-agnostic: the endpoint authorizes via selector+token and works whether or not the caller is logged in. The bundled admin UI calls it for both initial signup verification (kind: signup) and email-change confirmation (kind: change); see API reference → Verify endpoint response.
Rate Limiting
Spora’s rate limiter is a simple in-memory IP-based sliding window (Spora\Services\RateLimiter, app/Services/RateLimiter.php). The limit is applied on all auth-related endpoints, not only login:
| Endpoint Type | Limit | Source |
|---|---|---|
Authentication (/api/v1/auth/login, /api/v1/auth/register) | 5 req / 60 s | AuthController::RATE_LIMIT_* (app/Http/AuthController.php:26-27) |
Password reset (/api/v1/auth/forgot-password) | 5 req / 60 s | same constants |
Verification resend (/api/v1/auth/verification/resend) | 5 req / 60 s | same constants |
Other endpoints are not currently rate-limited.
Plugin Risks
Plugins are not sandboxed — they run as ordinary PHP code with full access to the application, the database, the file system, and any decrypted credentials. The plugin trust model and lifecycle are documented in the Plugin system(Open in new window) page under the Security section.
Trusted Proxies & X-Forwarded-* Headers
Spora does not read X-Forwarded-Host, X-Forwarded-Proto, or X-Forwarded-Port when resolving the public base URL used in transactional email links (verification, password reset). Those headers are spoofable by any direct client, and Spora has no trusted-proxy allowlist at the application layer — trusting them would let a remote attacker poison verification-link hostnames.
Operators behind a reverse proxy that rewrites Host MUST set SPORA_APP_URL in .env to the public origin (e.g. https://spora.example.com) — or, for shared hosting, set 'app_url' => 'https://spora.example.com' in config.php. The default SPORA_APP_PREFIX is /spora (matches the packaged admin UI and plugin URLs). Operators mounting Spora at the host root — e.g. when developing their own frontend — MUST set SPORA_APP_PREFIX="" to opt out. See Environment variables for the full reference.
Detection chain, first wins (app/Core/RequestOrigin.php, called with the merged config array):
config.phpapp_url(operator-pinned, shared-host friendly)SPORA_APP_URLenv var- Web-server
HTTP_HOST(request-supplied, may include:port) - Web-server
SERVER_NAME(ApacheServerNamedirective — set at server bootstrap, trusted because the operator controls it) http://localhost(CLI / worker / console / tests)
Path-prefix default: /spora (the packaged admin UI lives at public/spora/; plugins live at public/plugins/<name>/). Set SPORA_APP_PREFIX="" to mount Spora at the host root.
Do not run Spora behind a reverse proxy that does not rewrite Host to the public origin — without that rewrite, HTTP_HOST carries the internal hostname and verification links will not work.
Tool → user_id Trust Boundary
Tools never receive a session-derived user id. Orchestrator::safeExecute() (in app/Agents/Orchestrator.php) reads the calling Agent’s row and passes its user_id into ToolInterface::execute(). Tools therefore see the owner of the agent that issued the call, not “whoever is signed in” — a structural guarantee that no client code can bypass.
This matters most for:
- Async contexts — Worker mode, scheduled runs, and sub-agent hops all read the same agent row, so the trust boundary holds when no session exists.
- Multi-agent setups — a sub-agent created via
create_agent+configure_toolsinherits its owner fromconfigure_tools(agent_id: N, ...); cross-user ids are refused. - Audit trails — tool dispatch logs (
Orchestrator’sDEBUGTool dispatchrecord) carry the resolveduser_id, which is the onlyuser_ida tool can ever see.
Mercure data Projection Allowlist
SubAgentService::publishParentState() (app/Services/SubAgentService.php) projects a parent task’s data JSON column into the Mercure live-stream payload. To keep the SSE topic a non-leaky surface, only three keys are forwarded; everything else in data is dropped:
| Key | Purpose |
|---|---|
spawned_sub_task_ids | Live ids of sub-agent children; the dashboard needs to render progress. |
sub_agent_expected_count | Expected sibling count for the multi-child resume gate. |
run_id | Correlation id for the sub-agent hop. |
The allowlist is intentionally narrow because the parent task’s data column is a free-form JSON blob that may contain secrets the agent appended (api keys, intermediate reasoning, custom payloads). Operators auditing the SSE payload for PII/secrets need to know which keys are intentionally projected and why the projection is narrow — anything not on the allowlist is dropped, even if it would be useful to the SPA. If a future UI surface needs an additional data key, extend SubAgentService::PARENT_STATE_DATA_ALLOWLIST and audit the key for sensitive contents first.
Principal-scoped authorisation
Spora-core PR #209 re-keyed ownership from user_id to principal_id (see Concepts → Architecture → Principal ownership model). The principal is the auth axis for every ownership-touching endpoint. The contracts that operators auditing the API need to know:
PrincipalResolver::visiblePrincipalIds($userId)is the single source of truth for “who can this caller act as?”. Every endpoint that accepts aprincipal_idargument intersects it with this set.POST /api/v1/agents—principal_idbody field. Authorisation is gated byAgentPrincipalService::callerControlsPrincipal($userId, $principalId). The caller must be a global admin OR control the target principal (owner / admin of the group for a group-principal; trivially true for the caller’s own user-principal). When the caller doesn’t control the value, the controller falls back to the caller’s own user-principal — the requestedprincipal_idis never silently honoured, and no error is raised. This means an attacker who guesses a principal id sees no different response than they would have seen without supplying the field.GET /api/v1/agents?principal_id=— repeatable filter. Out-of-scope principal ids are silently dropped (existence-hiding). An empty filter returns every visible agent; a fully out-of-scope filter returns an empty list.POST /api/v1/agents/{id}/transfer— re-keysagents.principal_id. Caller must control BOTH source and target (admin/owner of source AND admin/owner of target, OR owner of target when the target is the caller’s own user-principal). Admins skip the source-side gate.403 FORBIDDENonUnauthorizedTransferException.404 NOT_FOUNDif either side is missing.DELETE /api/v1/groups/{id}— refuses with409 GROUP_HAS_AGENTS(carryingagent_idsandreassign_endpoint) if any agent still references the group’s principal. The operator must transfer or delete the dependents first. Theagents.principal_idFK usesON DELETE RESTRICT— there is no cascade-delete from principal to agent.Tool execution is principal-scoped, not user-scoped.
Orchestrator::safeExecute()now resolves aPrincipalContextfrom the calling agent’s row and passes it to every tool. The$userIdparameter plugins see isPrincipalResolver::ownerUserId($principalId)— the user that originally created the principal. A tool enabled on an agent owned by a group principal will see the group’s creator as its$userId, NOT a random group member.