Security
Stoa ships with security defaults that protect both the Admin Panel and the Storefront against common web vulnerabilities.
HTTP Security Headers
Stoa sets the following HTTP security headers on every response via a global middleware in internal/server/server.go:
| Header | Value | Purpose |
|---|---|---|
X-Content-Type-Options | nosniff | Prevents MIME-type sniffing — the browser must use the declared Content-Type |
X-Frame-Options | DENY | Blocks the page from being embedded in a <frame>, <iframe>, or <object> (clickjacking protection) |
X-XSS-Protection | 1; mode=block | Activates the legacy XSS auditor in older browsers and blocks the page on detection |
Referrer-Policy | strict-origin-when-cross-origin | Sends the full URL for same-origin requests; only the origin for cross-origin HTTPS requests; nothing for cross-origin HTTP |
Content-Security-Policy | default-src 'self' | Baseline CSP for API routes — see Content Security Policy for the full nonce-based policy applied to HTML pages |
Permissions-Policy | camera=(), microphone=(), geolocation=() | Disables access to the device camera, microphone, and geolocation APIs for all browsing contexts, including embedded iframes |
Permissions-Policy
Setting camera=(), microphone=(), and geolocation=() with empty allowlists means the features are disabled entirely — no origin, including the page's own origin, is granted access. This prevents malicious third-party scripts or iframes from requesting sensitive device permissions on behalf of the store.
Content Security Policy (CSP)
Stoa enforces a nonce-based Content Security Policy on every HTML page response. This prevents cross-site scripting (XSS) by ensuring only explicitly trusted scripts can execute.
How it works
Each time the Admin Panel or Storefront serves index.html, Stoa:
- Generates a cryptographically random 128-bit nonce (base64-encoded)
- Injects the nonce into the
Content-Security-Policyresponse header - Adds a
nonceattribute to every<script>tag in the HTML
The resulting CSP header looks like:
Content-Security-Policy:
default-src 'self';
script-src 'self' 'nonce-<random>' 'strict-dynamic';
style-src 'self' 'unsafe-inline''strict-dynamic'
The 'strict-dynamic' directive means that any script loaded by a nonced (trusted) script automatically inherits trust. This is critical for the plugin system — plugins load Web Components and external scripts (e.g. Stripe's js.stripe.com/v3/) dynamically via document.createElement('script'), and 'strict-dynamic' ensures these work without maintaining an explicit allowlist in script-src.
Plugin compatibility
Plugin developers do not need to do anything special for CSP. Scripts loaded dynamically from a trusted (nonced) bootstrap script inherit trust automatically via 'strict-dynamic'.
Plugin external scripts
Plugins that declare ExternalScripts in their UI extensions still have their domains added to script-src, frame-src, and connect-src as a CSP Level 2 fallback for older browsers that don't support 'strict-dynamic'.
What stays 'unsafe-inline'
style-src retains 'unsafe-inline' because:
- Many CSS-in-JS patterns and framework-generated inline styles require it
- Inline styles do not pose the same XSS risk as inline scripts
- Noncing every
<style>tag would add complexity without meaningful security gain
API routes
API routes (/api/v1/*) use a separate, stricter CSP (default-src 'self') since they return JSON, not HTML.
File Upload Validation
Stoa validates uploaded files server-side using magic byte detection, preventing attackers from bypassing restrictions by spoofing the Content-Type header.
How it works
When a file is uploaded via POST /api/v1/admin/media:
- The first 512 bytes of the file are read
- Go's
http.DetectContentTypeinspects the magic bytes to determine the actual MIME type - The detected type is checked against an allowlist of permitted MIME types
- If the type is not allowed, the request is rejected with
415 Unsupported Media Type
The client-provided Content-Type header is ignored for type determination — the server always trusts the file content over the header.
Allowed MIME types
| MIME Type | Description |
|---|---|
image/jpeg | JPEG images |
image/png | PNG images |
image/gif | GIF images |
image/webp | WebP images |
image/svg+xml | SVG vector graphics |
application/pdf | PDF documents |
Extending the allowlist
To allow additional file types, add entries to the allowedMIMETypes map in internal/domain/media/handler.go.
SVG handling
SVGs are detected by http.DetectContentType as text/xml or text/plain since they are XML-based text files. Stoa applies a special fallback: if the client header declares image/svg+xml and the detected type is text/xml or text/plain, the file is accepted as SVG.
Error response
Rejected uploads return:
{
"errors": [
{
"code": "unsupported_media_type",
"detail": "file type not allowed"
}
]
}Media Delete Path Validation
When a media file is deleted via DELETE /api/v1/admin/media/:id, Stoa resolves the stored relative path to an absolute filesystem path. To prevent directory traversal attacks, LocalStorage.Delete() applies a two-stage validation before calling os.Remove.
Stage 1 — Regex format check
The path must match the exact format produced by Store():
YYYY/MM/xxxxxxxx-filename| Segment | Rule |
|---|---|
YYYY | Four decimal digits (year) |
MM | Two decimal digits (month) |
xxxxxxxx | Exactly 8 lowercase hex characters (first 8 chars of a UUID v4) |
filename | One or more characters (the original filename) |
The regex enforced is ^\d{4}/\d{2}/[0-9a-f]{8}-.+$. Any path that does not match — including empty strings, bare filenames, and paths with uppercase characters in the prefix — is rejected before any filesystem access.
Stage 2 — Path containment check
Even after the regex passes, Stoa resolves the path to its absolute form and verifies it remains inside the configured basePath:
absBase, _ := filepath.Abs(basePath)
absPath, _ := filepath.Abs(filepath.Join(basePath, filepath.Clean(relPath)))
if !strings.HasPrefix(absPath, absBase+string(filepath.Separator)) {
return "", fmt.Errorf("path escapes base directory: %q", relPath)
}This is a defense-in-depth measure. Even a path that somehow passes the regex but contains encoded or OS-specific traversal sequences (e.g. backslash sequences on Windows) cannot escape the upload directory.
Why S3 is not affected
S3Storage uses object keys, not filesystem paths. S3 keys are opaque strings — there is no concept of directory traversal on the S3 API. Path validation applies only to LocalStorage.
Error responses
| Condition | Error message |
|---|---|
Path does not match YYYY/MM/xxxxxxxx-filename | invalid media path format: "<path>" |
Resolved path escapes basePath | path escapes base directory: "<path>" |
| File does not exist | Silent success (idempotent delete) |
Both error conditions cause Delete() to return an error, which the media handler translates to 400 Bad Request.
Related
See Media for a full reference of the media storage system, including storage backends, the Store() path format, and image processing.
Database Connection Security
Stoa defaults to sslmode=require for PostgreSQL connections, ensuring credentials and query data are encrypted in transit. At startup, Stoa checks the connection's TLS configuration and logs a warning if SSL is disabled:
WRN database connection uses sslmode=disable — not recommended for productionFor local development with Docker Compose, sslmode=disable is set via the STOA_DATABASE_URL environment variable — this is safe because traffic stays within Docker's internal bridge network.
See Database SSL/TLS for all available SSL modes.
Token Blacklisting
Stoa maintains an in-memory blacklist for revoked JWT access tokens. When a user logs out, the access token's JTI (unique token ID) is added to the blacklist, making it immediately unusable — even before the token's natural expiry.
How it works
- On logout, the access token from the
Authorizationheader is parsed and its JTI + expiry time are stored in the blacklist - Both
AuthenticateandOptionalAuthmiddleware check the blacklist before accepting a Bearer token - Blacklisted tokens in
Authenticatereturn401 Unauthorizedwith"token has been revoked" - Blacklisted tokens in
OptionalAuthare treated as anonymous (the request continues without auth context) - A background goroutine cleans up expired entries every minute — since access tokens live at most 15 minutes, memory usage stays minimal
Why in-memory?
Access tokens have a maximum lifetime of 15 minutes. An in-memory data structure with TTL-based cleanup is sufficient — there's no need for Redis or database storage. The blacklist is protected by a read-write mutex for concurrent access.
Scaling note
In a multi-instance deployment, each instance maintains its own blacklist. A token blacklisted on one instance won't be rejected by another. For single-instance deployments (the typical Stoa setup), this is not an issue. For multi-instance setups, consider placing a shared cache (e.g. Redis) behind a custom middleware.
Rate Limiting
Stoa applies rate limiting at two levels:
Global rate limit
A global IP-based rate limit applies to all API requests. The default is 300 requests per minute per IP with a burst allowance of 50.
Endpoint-specific rate limits
Sensitive endpoints have dedicated, stricter rate limits on top of the global limit. These protect against credential stuffing, account enumeration, and checkout abuse:
| Endpoint | Default Limit | Description |
|---|---|---|
POST /api/v1/auth/login | 10 req/min per IP | Prevents brute-force login attempts |
POST /api/v1/store/register | 5 req/min per IP | Prevents mass account creation |
POST /api/v1/store/checkout | 10 req/min per IP | Prevents checkout abuse |
GET /api/v1/store/orders/:id/transactions | 10 req/min per IP | Prevents guest token guessing |
When the limit is exceeded, the server responds with 429 Too Many Requests and includes a Retry-After header indicating how many seconds to wait before retrying.
HTTP/1.1 429 Too Many Requests
Retry-After: 42
{
"error": "Too Many Requests"
}Configuration
All rate limits are configurable in config.yaml:
security:
rate_limit:
requests_per_minute: 300 # Global limit
burst: 50
login:
requests_per_minute: 10
register:
requests_per_minute: 5
checkout:
requests_per_minute: 10
guest_order:
requests_per_minute: 10Or via environment variables:
STOA_SECURITY_RATE_LIMIT_LOGIN_REQUESTS_PER_MINUTE=10
STOA_SECURITY_RATE_LIMIT_REGISTER_REQUESTS_PER_MINUTE=5
STOA_SECURITY_RATE_LIMIT_CHECKOUT_REQUESTS_PER_MINUTE=10
STOA_SECURITY_RATE_LIMIT_GUEST_ORDER_REQUESTS_PER_MINUTE=10TIP
Endpoint-specific limits are independent — exhausting the login limit does not affect /refresh or /logout. Each IP address has its own counter.
Brute-force protection
In addition to IP-based rate limiting, Stoa has email-based brute-force protection on the login endpoint: after 5 failed attempts for the same email, the account is locked for 60 minutes. This works in tandem with rate limiting — rate limits protect against credential stuffing across different emails, while brute-force protection guards individual accounts.
The account-lock response always returns a fixed Retry-After: 3600 header regardless of the actual remaining lockout time. This prevents attackers from using the header value to determine exactly when a locked account becomes available again.
Guest Token Security
Guest orders use a cryptographically strong token for ownership verification. The token is never exposed in the API response body — it is delivered exclusively via an HTTP-only cookie.
Token generation
Each guest checkout generates a 32-byte random token using crypto/rand, hex-encoded to 64 characters. This provides 256 bits of entropy, making brute-force guessing infeasible.
Cookie delivery
The guest token is set as the stoa_guest_token cookie on the checkout response:
| Attribute | Value | Reason |
|---|---|---|
HttpOnly | true | Prevents JavaScript access (XSS protection) |
SameSite | Lax | Allows payment provider redirects (e.g. Stripe 3D Secure) |
Secure | Matches CSRF config | Set when serving over HTTPS |
Path | /api/v1/store | Scoped to store API routes |
MaxAge | 30 days | Covers typical order lifecycle |
The browser automatically includes this cookie on subsequent store API requests (e.g. fetching payment transactions), so guest ownership verification works transparently without exposing the token to client-side JavaScript.
Store vs. Admin API
- Store API — The checkout response includes
"is_guest_order": trueinstead of the raw token. The guest token cookie handles authentication. - Admin API — Returns the full
guest_tokenfield for debugging and payment reconciliation.
JWT Secret Validation
Stoa validates the JWT signing secret at startup and refuses to start if the secret is insecure.
Requirements
| Check | Behavior |
|---|---|
Default value change-me-in-production | Startup aborted with error |
| Length < 32 bytes | Startup aborted with error |
| Length 32–63 bytes | Startup proceeds with a warning |
| Length >= 64 bytes | No warning |
Generating a secure secret
# Generate a 64-byte random secret (recommended)
openssl rand -base64 64Set via environment variable or config.yaml:
STOA_AUTH_JWT_SECRET="your-secure-secret-here"If the secret fails validation, you'll see an error like:
jwt: default secret 'change-me-in-production' must not be used — set auth.jwt_secret in configor:
jwt: secret must be at least 32 bytes, got 12WARNING
The default value change-me-in-production is publicly known and must never be used outside of local development. Stoa will refuse to start with this value.
Email Normalization
All email addresses are normalized to lowercase with trimmed whitespace before any database lookup, uniqueness check, or brute-force tracking. This prevents inconsistencies between the application layer and the database.
Why this matters
Without normalization, an attacker could bypass account-level brute-force protection by varying the case of an email address — user@example.com, User@Example.COM, and USER@EXAMPLE.COM would each get their own failure counter, effectively tripling the number of allowed attempts before lockout.
Additionally, case-sensitive email uniqueness checks could allow duplicate accounts for the same mailbox (e.g. Alice@test.com and alice@test.com).
How it works
The auth.NormalizeEmail function is applied at every entry point that handles emails:
| Layer | Where | Effect |
|---|---|---|
| Login | Auth handler | Email is normalized before DB lookup and brute-force tracking |
| Brute-force tracker | IsLocked, RecordFailure, RecordSuccess | All case variants map to the same tracker key |
| Customer service | Create, Update, GetByEmail, VerifyCredentials | Emails are stored and queried in lowercase |
| Admin CLI | stoa admin create | Admin email is normalized before INSERT |
Database safety net
As an additional safeguard, the database enforces case-insensitive uniqueness via functional indexes:
CREATE UNIQUE INDEX idx_admin_users_email_lower ON admin_users (LOWER(email));
CREATE UNIQUE INDEX idx_customers_email_lower ON customers (LOWER(email));These indexes replace the original UNIQUE constraints on the email column. Even if a code path were to skip normalization, the database would reject case-variant duplicates.
Migration
Migration 000011_email_case_insensitive normalizes all existing emails to lowercase and creates the functional indexes. Run stoa migrate up to apply.
Authentication
See Authentication for details on JWT access/refresh tokens, RBAC roles, and CSRF protection.
CSRF Protection
Stoa uses the Double Submit Cookie pattern. State-changing requests (POST, PUT, PATCH, DELETE) must include an X-CSRF-Token header matching the csrf_token cookie value.
Two categories of requests are exempt from the CSRF check:
| Category | Reason |
|---|---|
Requests with an Authorization header (Bearer or ApiKey) | Cross-origin requests cannot set custom headers, so CSRF is impossible by design. |
Plugin webhook paths (/plugins/{name}/webhooks/*) | Webhooks authenticate via provider-specific signatures (e.g. Stripe HMAC), not cookies. |
All other plugin routes — /plugins/{name}/admin/*, /plugins/{name}/store/*, and /plugins/{name}/assets/* — require a valid CSRF token like any other cookie-authenticated request.
Plugin developers
Only /plugins/{name}/webhooks/* paths are CSRF-exempt. If your plugin registers admin or store endpoints under any other path pattern, those endpoints are protected by CSRF and your clients must send the X-CSRF-Token header on state-changing requests — unless they authenticate via an Authorization header instead.
Password Hashing
All passwords are hashed with Argon2id, the recommended algorithm for password storage per OWASP guidelines.
Timing-safe login
Login requests always perform a full Argon2id hash comparison, regardless of whether the user exists. When a login attempt targets a non-existent email address, Stoa verifies the submitted password against a pre-computed dummy hash instead of returning immediately. This ensures both code paths — user found with wrong password and user not found — take the same amount of time, preventing timing-based user enumeration.
Without this mitigation, an attacker could measure response times to determine which email addresses are registered: a fast response would indicate "user not found" (no hash computation), while a slow response would indicate "user exists" (Argon2id ran). The dummy hash uses the same DefaultParams (64 MB memory, 3 iterations) as real password hashes, making the timing indistinguishable.