Security

Built-in security defaults for common web app risks

Security

goilerplate ships with built-in security features for common web app risks. The default setup includes the main protections most SaaS apps need without adding a separate security plugin first.

Overview

Security is not an afterthought. Authentication, session management, and browser protections follow common OWASP guidance and modern web security defaults where applicable.


CSRF Protection

What is CSRF?

Cross-Site Request Forgery (CSRF) is an attack where a malicious website tricks your browser into making unwanted requests to our application while you’re logged in.

Example Attack:

1. You're logged in to your-app.com (cookie stored in browser)
2. You visit evil-site.com  
3. evil-site.com contains:
   <form action="https://your-app.com/delete-account" method="POST">
     <button>Win iPhone!</button>
   </form>
4. Your browser automatically sends your cookie with the request
5. Your account gets deleted without your consent

How We Protect You

goilerplate uses the Double Submit Cookie pattern - an industry standard that prevents CSRF attacks:

  1. Server generates random token and stores in HttpOnly cookie
  2. Token rendered in form as hidden input or meta tag
  3. On form submit, both cookie and form/header token are sent
  4. Server validates: cookie token must match form token
  5. Attacker cannot read cookie (HttpOnly + SameSite) so cannot forge valid request

Key Features:

  • HttpOnly cookies - JavaScript cannot steal tokens (prevents XSS attacks)
  • SameSite=Lax - Cookies not sent on cross-site requests
  • Constant-time comparison - Prevents timing attacks
  • Auto-protection - Works with all form submissions and HTMX (configured automatically in base layout). Other AJAX frameworks can read the meta tag.

Implementation

Forms (Traditional POST):

<form action="/auth/login" method="POST">
    @csrf.Token()  // ← Automatically adds hidden input
    <input name="email" type="email">
    <button>Login</button>
</form>

HTMX Requests (Ajax):

<!-- Meta tag in <head> - contains CSRF token -->
<meta name="csrf-token" content="...">

<!-- HTMX configured to read token and send as X-CSRF-Token header -->
<!-- Configuration in internal/ui/layouts/base.templ via htmx:configRequest event -->
<button hx-delete="/app/goals/123">Delete</button>

The base layout automatically configures HTMX to send the CSRF token with all requests. No additional setup needed.

Custom JavaScript:

  • Forms: Use @csrf.Token() component (works automatically)
  • fetch/AJAX: Read token from <meta name="csrf-token"> tag and send as X-CSRF-Token header

Example with fetch API:

const csrfToken = document.querySelector('meta[name="csrf-token"]').content;
fetch('/api/endpoint', {
    method: 'POST',
    headers: {
        'X-CSRF-Token': csrfToken,
        'Content-Type': 'application/json'
    },
    body: JSON.stringify(data)
});

Coverage:

  • All POST, PUT, PATCH, DELETE requests
  • Webhooks automatically exempted (/webhooks/*)
  • Middleware: middleware.CSRFProtection

Rate Limiting

Why Rate Limiting?

Rate limiting prevents:

  • Brute force attacks - Automated password guessing
  • Credential stuffing - Testing stolen credentials
  • DDoS attacks - Overwhelming server with requests

Implementation

Protected Endpoints:

  • /auth/magic-link - Magic link requests
  • /auth/password - Password login attempts
  • /auth/forgot-password - Password reset requests
  • /auth/google - OAuth initiation
  • /auth/github - OAuth initiation

Limits:

  • 5 requests per 15 minutes per IP address
  • Automatic cleanup (no memory leaks)
  • X-Forwarded-For / X-Real-IP support (works behind proxies)

Response:

HTTP 429 Too Many Requests
Too many requests. Please try again later.

Middleware: middleware.RateLimitAuth()

Scaling Considerations

The built-in rate limiter uses in-memory storage - perfect for single-server deployments (99% of apps).

For multi-server deployments, upgrade to Redis:

import "github.com/go-redis/redis_rate"

// Replace middleware.RateLimitAuth() with Redis-backed limiter

Security Headers

What Are Security Headers?

HTTP security headers are special response headers that tell browsers how to behave when handling your site’s content. They provide an additional layer of defense against common attacks.

Headers We Set

goilerplate automatically adds these security headers to every response:

X-Frame-Options: DENY

Protects against: Clickjacking attacks

What it does: Prevents your site from being embedded in an iframe on another site.

Example attack prevented:

1. evil-site.com embeds your site in invisible iframe
2. User thinks they're clicking "Win iPhone!" button on evil-site
3. They're actually clicking "Delete Account" on your site
4. X-Frame-Options blocks the iframe → attack fails

Why DENY? There’s rarely a legitimate reason to embed your app in an iframe. If you need iframe embedding for specific use cases, change to SAMEORIGIN or use CSP’s frame-ancestors.

X-Content-Type-Options: nosniff

Protects against: MIME-type confusion attacks

What it does: Forces browsers to respect the declared Content-Type header instead of trying to “guess” the file type.

Example attack prevented:

1. Attacker uploads "innocent.jpg" containing JavaScript
2. Server serves it with Content-Type: image/jpeg
3. Without nosniff: Browser detects JS, executes it → XSS!
4. With nosniff: Browser treats it as image → safe

Referrer-Policy: strict-origin-when-cross-origin

Protects against: Information leakage via Referer header

What it does: Controls how much information the browser sends when navigating away from your site.

  • Same-origin requests: Full URL sent (good for your analytics)
  • Cross-origin requests: Only domain sent, not full path (protects user privacy)
  • HTTPS→HTTP: No referrer sent (security downgrade protection)

Why this matters:

  • Prevents leaking sensitive URLs (/admin/users/123/edit)
  • Protects search queries (/search?q=medical+condition)
  • No session IDs in URLs leaked to third parties

Permissions-Policy

Protects against: Unauthorized feature access

What it does: Explicitly disables browser features your app doesn’t use.

Disabled features:

  • geolocation - GPS tracking
  • microphone - Audio recording
  • camera - Video/photo capture
  • payment - Payment Request API
  • usb - USB device access
  • magnetometer - Device orientation sensors

Why disable? Even if your code doesn’t use these features, third-party scripts or malicious XSS could try to access them. Disabling at the HTTP header level provides defense-in-depth.

Content-Security-Policy (CSP)

Protects against: Cross-Site Scripting (XSS) - the #1 web vulnerability

What it does: Defines which resources (scripts, styles, images, etc.) are allowed to load and execute.

Our CSP policy:

default-src 'self';
script-src 'self' 'nonce-abc123...' https://cdn.jsdelivr.net https://cdnjs.cloudflare.com
           https://www.googletagmanager.com https://plausible.io;
style-src 'self' 'unsafe-inline' https://cdnjs.cloudflare.com;
img-src 'self' data: https:;
font-src 'self' data:;
connect-src 'self' https://www.google-analytics.com https://analytics.google.com
            https://plausible.io;
frame-ancestors 'none';
base-uri 'self';
form-action 'self' https://sandbox.polar.sh https://polar.sh https://checkout.stripe.com;

How it prevents XSS:

  1. Attacker injects: <script>alert('XSS')</script>
  2. CSP blocks it because inline scripts without nonce are not allowed
  3. Attacker tries: <script src="http://evil.com/xss.js">
  4. CSP blocks it because only whitelisted domains are allowed

Nonce-Based CSP

What is a nonce? A “number used once” - a cryptographically random token that changes on every page load.

How it works:

  1. NonceMiddleware generates random nonce (16 bytes, base64-encoded)

    // internal/middleware/nonce.go
    nonce := "a1b2c3d4..." // changes every request
    ctx := templ.WithNonce(r.Context(), nonce)
    
  2. SecurityHeaders injects nonce into CSP header

    // CSP header sent to browser
    script-src 'self' 'nonce-a1b2c3d4...'
    
  3. Templates render scripts with matching nonce

    <script nonce={ templ.GetNonce(ctx) }>
      // This executes because nonce matches CSP header
      console.log('Allowed!');
    </script>
    
  4. Attacker cannot forge nonce (16 bytes = 2^128 possibilities)

    <!-- Attacker injects this -->
    <script>alert('XSS')</script>
    
    <!-- Browser blocks it: no nonce attribute → CSP violation -->
    

Using nonces in your templates:

// Inline scripts
<script nonce={ templ.GetNonce(ctx) }>
  // Your JavaScript here
</script>

// External scripts from your domain
<script defer nonce={ templ.GetNonce(ctx) } src="/assets/app.js"></script>

All scripts must have nonces! Forgotten nonces will be blocked by CSP.

CDN Scripts (Whitelisted)

Allowed CDNs (pre-configured):

  • cdn.jsdelivr.net - htmx library
  • cdnjs.cloudflare.com - highlight.js for code blocks

These don’t need nonces because they’re in the CSP whitelist.

Why whitelist? htmx and highlight.js are loaded from CDN for performance. We trust these domains (open-source libraries with SRI/integrity checks where available).

Analytics & External Scripts

Optional integrations (commented out by default):

If you enable Google Analytics, Plausible, or Clarity, you must add their domains to CSP:

// internal/middleware/security_headers.go
cspPolicy := strings.Join([]string{
  "default-src 'self'",
  "script-src 'self' 'nonce-" + nonce + "' https://cdn.jsdelivr.net https://cdnjs.cloudflare.com https://www.googletagmanager.com", // ← Added
  "connect-src 'self' https://www.google-analytics.com", // ← Added for GA
  // ... rest of policy
}, "; ")

Analytics domains needed:

  • Google Analytics: www.googletagmanager.com, www.google-analytics.com (script-src + connect-src)
  • Plausible: plausible.io (script-src + connect-src)
  • Microsoft Clarity: www.clarity.ms (script-src + connect-src)

See comments in internal/middleware/security_headers.go (lines 170-180) for complete instructions.

Why ‘unsafe-inline’ for styles? CSS cannot execute JavaScript, so inline styles are safe. Tailwind and templ both generate inline styles, and blocking them would break the UI. This is standard practice.

Middleware Chain

Security headers are set by two middleware working together:

  1. middleware.NonceMiddleware - Generates unique CSP nonce per request

    • Location: internal/middleware/nonce.go
    • Runs first (before SecurityHeaders)
    • Stores nonce in context for templates and SecurityHeaders
  2. middleware.SecurityHeaders - Sets all HTTP security headers

    • Location: internal/middleware/security_headers.go
    • Reads nonce from context
    • Injects into CSP header: script-src 'self' 'nonce-abc123...'

Middleware order (in internal/routes/routes.go):

handler := middleware.Chain(
  mux,
  middleware.NonceMiddleware,   // ← Generate nonce FIRST
  middleware.SecurityHeaders,   // ← Use nonce for CSP
  // ... other middleware
)

Order matters! NonceMiddleware must run before SecurityHeaders.

Customization

If you need to modify headers (e.g., to allow external scripts or CDN resources):

  1. Edit internal/middleware/security_headers.go
  2. Add your domain to the appropriate CSP directive:
// Example: Allow Google Analytics
"script-src 'self' 'nonce-" + nonce + "' https://cdn.jsdelivr.net https://cdnjs.cloudflare.com https://www.googletagmanager.com",

// Example: Allow external fonts
"font-src 'self' data: https://fonts.gstatic.com",

// Example: Allow CDN images
"img-src 'self' data: https: https://your-cdn.com",

Important: Keep the 'nonce-" + nonce + "' part! Don’t use literal 'nonce-{nonce}'.

Testing CSP

Browser DevTools shows CSP violations in the Console tab:

Refused to execute inline script because it violates the following
Content Security Policy directive: "script-src 'self' 'nonce-abc123...'".
Either the 'unsafe-inline' keyword, a hash, or a nonce is required.

Testing without blocking:

  1. Edit internal/middleware/security_headers.go
  2. Change header name:
    // Test mode: log violations but don't block
    w.Header().Set("Content-Security-Policy-Report-Only", cspPolicy)
    
  3. Browse your site and check Console for violations
  4. Fix issues (add missing nonces, whitelist domains)
  5. Switch back to enforcing:
    w.Header().Set("Content-Security-Policy", cspPolicy)
    

Common CSP violations:

  • <script> without nonce → Add nonce={ templ.GetNonce(ctx) }
  • External script not whitelisted → Add domain to script-src
  • AJAX to external API → Add domain to connect-src

OAuth Security

State Parameter Validation

OAuth flows are protected against CSRF attacks using state parameters:

  1. Generate random state token (32 bytes, cryptographically secure)
  2. Store in encrypted session cookie (HttpOnly, 10min expiry)
  3. Redirect to OAuth provider with state parameter
  4. Validate on callback: URL state must match cookie state
  5. Reject if mismatch - prevents account hijacking

Implementation:

  • Google OAuth: auth.GoogleAuthauth.GoogleCallback
  • GitHub OAuth: auth.GitHubAuthauth.GitHubCallback

This prevents attackers from tricking users into linking attacker’s OAuth account to victim’s profile.


Password Security

Hashing Algorithm

bcrypt - Battle-tested, industry-standard password hashing algorithm.

Parameters:

  • Cost factor: 10 (default, ~100ms per hash)
  • Automatic salt generation (built-in)
  • 72-byte maximum password length

Why bcrypt?

  • ✅ Battle-tested for 25+ years (used by GitHub, Stripe, Shopify)
  • ✅ Adaptive cost factor (can increase over time as hardware improves)
  • ✅ Slow by design (resists brute-force attacks)
  • ✅ Built-in salt (no separate salt management needed)
  • ✅ Well-supported across all platforms and languages

Note: While Argon2id is technically more modern, bcrypt remains the pragmatic choice for most applications. The security difference is marginal for properly configured passwords (12+ characters). Both are OWASP-approved.

Password Requirements

Minimum length: 12 characters

Research shows length > complexity for security. A 12-character password is harder to crack than an 8-character password with symbols.

The password rules are based on NIST recommendations around longer passwords and avoiding unnecessary composition rules. This is about password policy design, not a claim of general NIST compliance.


JWT Token Security

Token Storage

HttpOnly cookies - NOT localStorage!

Why?

  • ❌ localStorage accessible by JavaScript (XSS vulnerability)
  • ✅ HttpOnly cookies protected from JavaScript access
  • ✅ Automatic transmission (no manual header setting)
  • ✅ SameSite protection (CSRF mitigation)

Token Expiry

Default: 7 days (configurable via JWT_EXPIRY env var)

After expiry, users must re-authenticate. Balances security (shorter = more secure) with UX (longer = less re-login).

Token Claims

Minimal claims for security:

{
  "user_id": "uuid-here",
  "exp": 1234567890
}

No sensitive data in JWT (email, name, etc.) - fetch from database on each request.

Token Cleanup (Optional)

One-time tokens (magic links, email verification, password reset) are NOT automatically deleted by design.

Why keep them?

  • Audit trail for security investigations
  • Detect abuse patterns and replay attacks
  • Compliance requirements (GDPR, HIPAA)

When to cleanup:

  • High volume apps (>10k tokens/day)
  • Data retention policies require it
  • Database storage is constrained

How to cleanup:

// Optional: Delete used tokens older than 90 days
deleted, err := tokenRepo.CleanupExpired(90 * 24 * time.Hour)

For most apps, cleanup is unnecessary. Tokens are lightweight and used tokens are already invalidated.


Email Verification

Token expiry: 10 minutes (configurable via TOKEN_MAGIC_LINK_EXPIRY)

Why short expiry?

  • Limits window for token theft (email forwarding, shoulder surfing)
  • Forces timely action (reduces abandoned registrations)
  • Industry standard (Google uses 15min, GitHub 10min)

Password Reset: 1 hour (configurable via TOKEN_PASSWORD_RESET_EXPIRY)

Email Change: 24 hours (configurable via TOKEN_EMAIL_CHANGE_EXPIRY)

Token Format

  • Random 32-byte token (cryptographically secure)
  • URL-safe base64 encoding
  • Stored hashed in database (prevents theft if DB compromised)
  • Single-use (deleted after verification)

File Upload Security

What is protected

The default file validation covers the main risks around user-uploaded files:

  • Accepting a file that only pretends to be an image
  • Letting oversized uploads through
  • Trusting the browser-provided Content-Type too much
  • Relying only on the file extension

How validation works

File uploads are validated in internal/validation/file.go.

For image uploads, the default rules are:

  • Allowed MIME types: image/jpeg, image/png, image/webp
  • Allowed extensions: .jpg, .jpeg, .png, .webp
  • Maximum size: 5 MB

The validator checks:

  1. File size before reading content
  2. Actual file content with http.DetectContentType
  3. File extension against an allowlist

This means changing the filename or request header alone is not enough to bypass validation.

Why magic-number detection matters

Browsers and clients can send misleading file names or Content-Type headers. The validator reads the first bytes of the file and detects the real content type from the file itself.

That is a stronger check than trusting:

  • avatar.png as a filename
  • Content-Type: image/png from the request

Current default scope

The built-in app uses this for avatar uploads out of the box. The validation helpers are reusable if you later add document uploads or other file types.


Security Checklist

Before deploying to production:

Environment Variables

  • Set strong JWT_SECRET (32+ random characters)
  • Configure S3 credentials (S3_ACCESS_KEY, S3_SECRET_KEY)
  • Set up email service (RESEND_API_KEY)
  • Add OAuth credentials if using social login

HTTPS

  • Enable HTTPS (required for secure cookies)
  • Redirect HTTP → HTTPS
  • Set APP_ENV=production (enables Secure flag on cookies)

Security Headers

  • Security headers enabled (automatic via middleware.SecurityHeaders)
  • CSP nonces generated per-request (automatic via middleware.NonceMiddleware)
  • S3/CDN endpoints dynamically whitelisted in CSP (automatic)
  • Review CSP policy if using external scripts/CDNs (see Analytics section)
  • Test CSP in browser DevTools Console for violations
  • Verify X-Frame-Options doesn’t break legitimate iframe usage (if any)
  • Add nonce={ templ.GetNonce(ctx) } to all new <script> tags in templates

Input Validation

  • Email validation using RFC 5322 compliant parser (net/mail)
  • Email length limit enforced (max 254 characters)
  • Name length limit enforced (max 100 characters)
  • Password strength validation (min 12 chars, common patterns blocked)

Rate Limiting

  • Auth endpoints rate-limited (5 requests / 15 minutes per IP)
  • OAuth initiation rate-limited (/auth/google, /auth/github)
  • OAuth callbacks rate-limited (/auth/google/callback, /auth/github/callback)
  • X-Forwarded-For and X-Real-IP support (works behind load balancers)

File Upload Security

  • File type validation using magic numbers (actual content check)
  • Size limits enforced (max 5MB for avatars)
  • Extension validation (.jpg, .jpeg, .png, .webp)
  • Content-Type validation cannot be bypassed (uses http.DetectContentType)

Token Security

  • Cryptographically secure random tokens (32 bytes)
  • Token expiry enforced (magic link: 10min, password reset: 1h, email change: 24h)
  • Single-use tokens (marked as used after verification)
  • Race condition prevention (database-level atomicity check)
  • Expired tokens automatically rejected

Database

  • Use strong database password
  • Enable SSL/TLS for database connections (production)
  • Regular backups configured

Monitoring

  • Set up Sentry for error tracking (optional)
  • Monitor server logs for auth failures
  • Set up uptime monitoring (optional)

Compliance

OWASP Top 10 Coverage

  • A01: Broken Access Control - RequireAuth middleware, CSRF protection
  • A02: Cryptographic Failures - bcrypt, HTTPS, secure cookies
  • A03: Injection - Prepared statements (SQL injection prevention)
  • A04: Insecure Design - Security headers (CSP, X-Frame-Options, etc.)
  • A05: Security Misconfiguration - Secure defaults, security headers
  • A07: Authentication Failures - Rate limiting, strong passwords, CSRF

FAQ

Q: Is rate limiting enough for production?
A: Yes, for single-server deployments. Upgrade to Redis for multi-server.

Q: Should I add reCAPTCHA?
A: Start without it. Add if you see bot activity. Rate limiting handles most cases.

Q: What about SQL injection?
A: Protected by default - all database queries use prepared statements.

Q: Why bcrypt instead of Argon2id?
A: bcrypt is battle-tested, widely used (GitHub, Stripe, Shopify), and sufficient for 99.9% of applications. While Argon2id is technically more modern, the security difference is marginal for properly configured passwords. Both are OWASP-approved. Choose bcrypt for simplicity and compatibility, or Argon2id if you need cutting-edge security (defense industry, government, etc.).

Q: Can I disable CSRF for API endpoints?
A: CSRF middleware already exempts /webhooks/*. For custom APIs, add exemption in middleware/csrf.go.

Q: My inline script isn’t working. CSP violation in console?
A: Add nonce={ templ.GetNonce(ctx) } to your <script> tag. ALL inline scripts must have nonces or CSP will block them.

Q: Can I use ‘unsafe-inline’ instead of nonces?
A: No! This defeats the entire purpose of CSP. Nonces are the secure way to allow inline scripts while blocking XSS attacks.

Q: Do CDN scripts need nonces?
A: No. External scripts loaded via <script src="https://..."> are whitelisted by domain in CSP. Only inline scripts (no src attribute) need nonces.


Additional Resources


Built with security in mind, with sensible defaults you can review and extend.