This document describes Brace's security features and best practices for building secure applications.
- Sessions
- CSRF Protection
- Trusted Proxies
- Cookie Security
- File Uploads
- Rate Limiting
- Ops Endpoints
- Secrets Management
Brace session cookies are encrypted using AES-256-GCM.
- ✅ Confidentiality: Data cannot be read by the client
- ✅ Integrity: Data cannot be tampered with
- ✅ Authenticity: Only the server can create valid sessions
✅ Safe to store (encrypted):
- User ID
- Email addresses
- Permissions, roles, or scopes
- User preferences (theme, language, timezone)
- CSRF tokens
- Flash messages (transient UI notifications)
- Shopping cart contents (within size limits)
Cookies have a 4KB size limit. For large session data:
- Use server-side storage (database, Redis, etc.)
- Store a session ID in the cookie and look up the rest server-side
- Algorithm: AES-256-GCM (Galois/Counter Mode)
- Key Derivation: PBKDF2-HMAC-SHA256 (100,000 iterations)
- Authentication: GCM mode provides built-in authentication (no separate HMAC needed)
- Nonce: Random 12-byte nonce per cookie (prevents replay attacks)
// Store user info directly in the encrypted session
session.set("userId", user.id.toString());
session.set("email", user.email);
session.set("role", user.role);
// Retrieve on subsequent requests
var userId = session.getLong("userId");
var email = session.get("email");
var role = session.get("role");For very large session data, you can still use server-side storage:
// Store only an opaque session ID in the cookie
session.set("sessionId", UUID.randomUUID().toString());
// Store large data in the database
var userSession = new UserSession();
userSession.sessionId = session.get("sessionId");
userSession.userId = user.id;
// ... store large data here ...
db.insert(userSession);Brace automatically validates CSRF tokens on mutating requests (POST, PUT, DELETE, PATCH).
- CSRF tokens are automatically generated and stored in the session
- The token must be included in requests as:
- Form parameter:
_csrf - Header:
X-CSRF-Token
- Form parameter:
- Validation happens automatically before your handler runs
CSRF validation is skipped for:
- GET, HEAD, OPTIONS requests (safe methods)
- Requests with
Content-Type: application/json(assumed to be APIs)
- For HTML forms: Include the CSRF token field (automatically available in templates)
- For JSON APIs with cookies: Either:
- Require CSRF token in request header
- Use bearer token authentication (no cookies)
- For public APIs: Use API keys or OAuth, not cookie-based sessions
When running behind a reverse proxy (nginx, Caddy, load balancer), you must explicitly configure trusted proxies.
Without trusted proxy configuration, attackers can spoof their IP address by sending fake X-Forwarded-For headers, bypassing:
- Rate limiting
- IP-based access control
- Audit logs
- Geofencing
// Trust private network proxies
app.trustedProxies("10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16");
// Trust specific proxy IPs
app.trustedProxies("10.0.0.5", "10.0.0.6");
// Trust cloud provider ranges (example: AWS)
app.trustedProxies("10.0.0.0/8");- Without configuration:
req.ip()uses socket remote address only (ignores headers) - With configuration:
req.ip()parsesX-Forwarded-For/Forwardedonly if the immediate peer is trusted
X-Forwarded-For(most common)Forwarded(RFC 7239)
Configure session cookie security with SessionOptions:
// Secure defaults for production
app.sessions(SessionOptions.secure("your-secret")
.maxAgeDays(14)
.sameSiteLax());
// Custom configuration
app.sessions(SessionOptions.of("your-secret")
.secure(true) // HTTPS only
.httpOnly(true) // No JavaScript access
.sameSiteStrict() // Strict CSRF protection
.maxAgeDays(30) // 30-day expiration
.domain(".example.com") // Share across subdomains
.path("/app")); // Restrict to path| Attribute | Default | Purpose |
|---|---|---|
HttpOnly |
true |
Prevents JavaScript access (XSS mitigation) |
Secure |
false |
HTTPS only (set to true in production) |
SameSite |
Lax |
CSRF protection (Strict, Lax, or None) |
Max-Age |
session | Cookie lifetime (use maxAgeDays() to set) |
Path |
/ |
Scope to specific path |
Domain |
none | Share across subdomains |
app.sessions(SessionOptions.secure("secret")
.maxAgeDays(14)
.sameSiteLax());This sets: HttpOnly=true, Secure=true, SameSite=Lax, Max-Age=1209600 (14 days)
Configure maximum upload size to prevent DoS:
app.maxUploadSize("10M"); // 10 megabytes (default)
app.maxUploadSize("50M"); // 50 megabytes- Validate file types: Check
file.contentType()and extension - Scan for malware: Use external virus scanning for untrusted uploads
- Store safely: Don't use user-provided filenames directly
- Limit concurrency: High upload concurrency can exhaust memory
// DON'T: Use user filename directly
String key = "uploads/" + file.name(); // ❌ Unsafe
// DO: Generate safe keys
String key = storage.safeKey("uploads", file.name()); // ✅ SafeProtect endpoints from abuse with rate limiting:
// Per-IP rate limiting
app.before("/api", RateLimiter.perIp(100, "1m"));
// Per-user rate limiting
app.before("/api", RateLimiter.perKey(
req -> req.header("Authorization"),
1000,
"1h"
));
// Custom key function
app.before("/login", RateLimiter.perKey(
req -> req.param("username"),
5,
"15m"
));- Configure trusted proxies first (otherwise IP-based limiting is ineffective)
- Use different limits for different endpoints:
- Login: 5 attempts per 15 minutes
- API: 100-1000 requests per hour
- Anonymous: 10 requests per minute
- Combine with authentication for logged-in users
Ops endpoints (/ops/*) provide powerful observability and control. Secure them carefully.
Ops endpoints use public key authentication:
app.ops("authorized-keys");The authorized-keys file contains public keys of clients allowed to access ops endpoints.
- HTTPS only: Never expose ops endpoints over HTTP
- Restrict at reverse proxy: Use IP allowlisting at nginx/Caddy
- Don't expose publicly: Ops endpoints should not be internet-accessible
- Rotate keys regularly: Implement key rotation for ops access
- Monitor access: Log all ops endpoint access
# nginx config
location /ops/ {
allow 10.0.0.0/8; # Internal network only
deny all;
proxy_pass http://app;
}- Minimum: 32 bytes of random data
- Generate with:
openssl rand -base64 32oruuid4().toString() - Never use: "secret", "changeme", "test123", predictable values
Store secrets in environment variables, not in code:
var secret = System.getenv("SESSION_SECRET");
if (secret == null) {
throw new IllegalStateException("SESSION_SECRET not set");
}
app.sessions(secret);Use Config for environment-aware configuration:
var config = Config.load();
app.sessions(config.require("session.secret"));
app.ops(config.get("ops.keys.path", "authorized-keys"));When rotating secrets:
- Sessions: Users will be logged out on rotation
- Ops keys: Add new keys before removing old ones
- Database credentials: Use connection pooling with graceful reload