CSRF Protection
> Protect your Velocity application against cross-site request forgery attacks with built-in CSRF middleware.
Velocity provides comprehensive CSRF (Cross-Site Request Forgery) protection to secure your application against unauthorized form submissions and state-changing requests.
Quick Start
velocity.New builds the CSRF instance for you from Config.CSRF
(seeded from csrf.DefaultConfig() and overridable via CSRF_*
environment variables) and exposes it on the app as v.CSRF
(type contract.CSRFProtector, concrete *csrf.CSRF). It also installs
a SessionIDResolver that decrypts the session cookie - the token store
is keyed by the plaintext session ID, never by the raw cookie value.
To build an instance by hand (custom middleware stacks, tests), use the
error-returning constructor and supply a SessionIDResolver - it is
required:
import (
"net/http"
"github.com/velocitykode/velocity/csrf"
"github.com/velocitykode/velocity/csrf/stores"
)
config := csrf.DefaultConfig()
config.Store = stores.NewSessionStore(config.TokenLifetime)
config.SessionIDResolver = func(r *http.Request) (string, error) {
// Return the plaintext session ID, or csrf.ErrNoSession when the
// request carries no session. Keying tokens by an unauthenticated
// cookie value is rejected at construction time.
if c, err := r.Cookie("session_id"); err == nil && c.Value != "" {
return c.Value, nil
}
return "", csrf.ErrNoSession
}
protection, err := csrf.NewE(config)
if err != nil {
// ErrInsecureCSRFConfig: nil resolver or unsupported Mode.
panic(err)
}csrf.New(config) is the panic-on-error variant of NewE.
// Apply CSRF to the web middleware stack
v.Middleware(func(m *velocity.MiddlewareStack) {
csrfInstance := v.CSRF.(*csrf.CSRF)
m.Web(
csrfInstance.RouterMiddleware(), // validates token on unsafe methods
)
})RouterMiddleware() returns a router.MiddlewareFunc that resolves the
session ID, looks up the token, and validates the request. The session
middleware is installed globally by velocity.New, so it already runs
ahead of the web stack.
<!-- Meta tag: render the token your shared-props function published -->
<head>
<meta name="csrf-token" content="{{ .csrfToken }}">
</head>
<!-- Form field: include the token on unsafe requests -->
<form method="POST" action="/submit">
<input type="hidden" name="_token" value="{{ .csrfToken }}">
<input type="text" name="email" />
<button type="submit">Submit</button>
</form>
<script>
// Read the token for AJAX
const token = document.querySelector('meta[name="csrf-token"]').content;
fetch('/api/data', {
method: 'POST',
headers: {
'X-CSRF-Token': token,
'Content-Type': 'application/json'
},
body: JSON.stringify(data)
});
</script>The token is obtained via csrf.TokenForRequest(r), which returns the
per-response masked form (memoized for the request so the meta tag,
form field, and XSRF-TOKEN cookie all carry byte-identical values).
Plug it into the view engine’s shared props under the key your template
reads:
engine.SetSharePropsFunc(func(r *http.Request) (view.Props, error) {
props := view.Props{}
if token, err := csrf.TokenForRequest(r); err == nil && token != "" {
props["csrfToken"] = token
}
return props, nil
})TokenForRequest requires the CSRF middleware to have run on the request
(it attaches the request-scoped token state); it returns
csrf.ErrNoTokenState otherwise, which callers treat as “no token to
embed”.
// SPAs read the token from a refresh endpoint
v.Routes(func(r *velocity.Routing) {
csrfInstance := v.CSRF.(*csrf.CSRF)
r.Web(func(web router.Router) {
web.Get("/csrf/token", func(c *router.Context) error {
csrfInstance.RefreshHandler()(c.Response, c.Request)
return nil
})
})
})Exclude the refresh endpoint from CSRF validation itself:
config.ExcludePaths = []string{"/csrf/token"}Configuration
Default Configuration
config := csrf.DefaultConfig()
// Returns:
// {
// TokenLifetime: 24 * time.Hour,
// HeaderName: "X-CSRF-Token",
// FormField: "_token",
// CookieName: "csrf_token",
// SessionCookieName: "session_id",
// MaxFormBodyBytes: 1 << 20, // csrf.DefaultMaxFormBodyBytes (1 MiB)
// Mode: csrf.ModeSession,
// SameSite: http.SameSiteLaxMode,
// Secure: true,
// HttpOnly: true,
// SingleUse: false,
// WriteXSRFCookie: true,
// XSRFCookieName: "XSRF-TOKEN",
// ErrorMessage: "CSRF token validation failed. Please refresh and try again.",
// }Custom Configuration
config := &csrf.Config{
// Token settings
TokenLifetime: 12 * time.Hour, // Token expiration
HeaderName: "X-CSRF-Token", // Header name for token
FormField: "_token", // Form field name
CookieName: "csrf_token", // Cookie name
SessionCookieName: "velocity_session", // Session cookie name
MaxFormBodyBytes: 1 << 20, // urlencoded body cap (default 1 MiB)
// Binding mode (only ModeSession is implemented today)
Mode: csrf.ModeSession,
// Security settings
SameSite: http.SameSiteStrictMode, // CSRF protection level
Secure: true, // HTTPS only
HttpOnly: true, // No JavaScript access (matches net/http casing)
SingleUse: false, // Reusable tokens
// axios/Angular convenience cookie (non-HttpOnly XSRF-TOKEN)
WriteXSRFCookie: true,
XSRFCookieName: "XSRF-TOKEN",
// Storage
Store: stores.NewSessionStore(), // Token storage
// Required: resolve the plaintext session ID tokens are keyed by.
SessionIDResolver: resolveSession,
// Exception handling
ExcludePaths: []string{ // Paths to exclude
"/api/webhooks/*",
"/health",
},
ExcludeFunc: func(r *http.Request) bool {
// Custom exclusion logic
return strings.HasPrefix(r.URL.Path, "/public/")
},
// Error handling
ErrorMessage: "Invalid CSRF token",
ErrorHandler: customErrorHandler,
}SessionIDResolver is required: csrf.NewE (and csrf.New) reject a nil
resolver with ErrInsecureCSRFConfig. It must return the plaintext
session ID, or csrf.ErrNoSession when the request carries no session.
velocity.New installs an encrypted-session resolver automatically when
the app encryptor, session cookie name, and SessionCookieName all align;
otherwise it installs a strict-reject resolver so the deployment fails
closed (419 on every unsafe request) instead of silently bypassing CSRF.Validation
Config.Validate(env) rejects insecure configurations at boot.
velocity.New calls it for you. The rules:
Modemust beModeSession(ModeDoubleSubmitis reserved).HttpOnlymust betrueunlessAllowJSAccessis set.Securemust betrueoutside a dev/test profile (APP_ENVone ofdevelopment,dev,test,testing,local).SameSitemust be explicit (not the zero/default value).SameSite=NonerequiresSecure=true.
Environment variables
velocity.New overrides the package defaults from these variables:
| Variable | Field |
|---|---|
CSRF_TOKEN_LIFETIME | TokenLifetime |
CSRF_HEADER | HeaderName |
CSRF_FORM_FIELD | FormField |
CSRF_COOKIE_NAME | CookieName |
CSRF_SESSION_COOKIE | SessionCookieName (defaults to the session cookie name) |
CSRF_SAME_SITE | SameSite |
CSRF_SECURE | Secure (set to false to disable) |
CSRF_HTTP_ONLY | HttpOnly |
CSRF_SINGLE_USE | SingleUse |
CSRF_WRITE_XSRF_COOKIE | WriteXSRFCookie |
CSRF_XSRF_COOKIE_NAME | XSRFCookieName |
CSRF_ERROR_MESSAGE | ErrorMessage |
Token Storage Strategies
Session Store (Default)
Server-side token storage using sessions. Most secure for traditional web applications.
import "github.com/velocitykode/velocity/csrf/stores"
config := csrf.DefaultConfig()
config.Store = stores.NewSessionStore()Pros:
- Most secure (server-side validation)
- Works with server-side sessions
- Tokens never exposed to client
Cons:
- Requires session management
- Not suitable for stateless APIs
Custom Store Implementation
The csrf.Store interface has four methods: Get, Set, Delete, and
Exists. Get must return stores.ErrTokenNotFound for a missing entry
(GetToken mints a fresh token only on that sentinel; any other error is
surfaced unchanged).
import "github.com/velocitykode/velocity/csrf/stores"
type CustomStore struct {
cache map[string]string
mu sync.RWMutex
}
func (s *CustomStore) Get(id string) (string, error) {
s.mu.RLock()
defer s.mu.RUnlock()
token, exists := s.cache[id]
if !exists {
return "", stores.ErrTokenNotFound
}
return token, nil
}
func (s *CustomStore) Set(id string, token string) error {
s.mu.Lock()
defer s.mu.Unlock()
s.cache[id] = token
return nil
}
func (s *CustomStore) Delete(id string) error {
s.mu.Lock()
defer s.mu.Unlock()
delete(s.cache, id)
return nil
}
func (s *CustomStore) Exists(id string) bool {
s.mu.RLock()
defer s.mu.RUnlock()
_, ok := s.cache[id]
return ok
}
// Use custom store
config.Store = &CustomStore{
cache: make(map[string]string),
}csrf.AtomicConsumer interface
(ConsumeIfMatch(id, expected string) (bool, error)). Without it, the
middleware falls back to a per-process lock and logs a one-time warning -
single-use is then best-effort across replicas.Template integration
Expose the token to templates as a shared prop (e.g. csrfToken) by
publishing csrf.TokenForRequest(r) from your SetSharePropsFunc (see
Quick Start). Include it in your root template’s meta tag
and in any form that submits to an unsafe method:
<meta name="csrf-token" content="{{ .csrfToken }}">
<form method="POST" action="/submit">
<input type="hidden" name="_token" value="{{ .csrfToken }}">
<!-- other fields -->
</form>The middleware looks for the token in this order:
- The configured header (
config.HeaderName, defaultX-CSRF-Token). - The
X-XSRF-TOKENheader (axios/Angular convention - clients echo the URL-encodedXSRF-TOKENcookie value). - The
_tokenform field (settable viaconfig.FormField), parsed only from anapplication/x-www-form-urlencodedbody up toconfig.MaxFormBodyBytes. Multipart bodies are never parsed - send the token in a header instead.
Tokens are emitted in a per-response masked form (see csrf.MaskToken)
so identical bytes never repeat across responses, defeating BREACH-style
compression-oracle extraction. The middleware accepts both the masked form
and a raw framework-length token.
Middleware integration
Applying CSRF to the web stack
Add RouterMiddleware() to the web middleware list so it runs on all
browser routes:
v.Middleware(func(m *velocity.MiddlewareStack) {
csrfInstance := v.CSRF.(*csrf.CSRF)
m.Web(
csrfInstance.RouterMiddleware(), // CSRF validation on unsafe methods
)
})The session middleware is registered globally by velocity.New and runs
ahead of the web stack, so the session ID is resolvable by the time CSRF
validation runs.
Selective application
To apply CSRF only to a subset of routes, omit it from the web stack and add it to a specific group instead:
v.Routes(func(r *velocity.Routing) {
csrfInstance := v.CSRF.(*csrf.CSRF)
r.Web(func(web router.Router) {
web.Get("/", handlers.Home)
web.Group("/account", func(acc router.Router) {
acc.Post("/update", handlers.AccountUpdate)
acc.Delete("/delete", handlers.AccountDelete)
}).Use(csrfInstance.RouterMiddleware())
})
})Conditional bypass
Use ExcludeFunc on the config rather than wrapping middleware:
config.ExcludeFunc = func(r *http.Request) bool {
// skip CSRF for requests carrying a bearer token
return strings.HasPrefix(r.Header.Get("Authorization"), "Bearer ")
}Path Exclusions
Wildcard Patterns
config.ExcludePaths = []string{
"/api/webhooks/*", // All webhook endpoints
"/health", // Exact match
"/metrics", // Exact match
"/public/*", // All public endpoints
}Custom Exclusion Logic
config.ExcludeFunc = func(r *http.Request) bool {
// Exclude if API key is present
if r.Header.Get("X-API-Key") != "" {
return true
}
// Exclude if OAuth bearer token
if strings.HasPrefix(r.Header.Get("Authorization"), "Bearer ") {
return true
}
// Exclude specific user agents (e.g., monitoring tools)
if strings.Contains(r.UserAgent(), "Monitoring") {
return true
}
return false
}AJAX and Single Page Applications
Setting Up for SPAs
Expose a refresh endpoint and exclude it from CSRF validation:
// Config: exclude the refresh endpoint from validation. With velocity.New
// the CSRF instance is built from Config.CSRF, so set this on the config
// (or via CSRF_* env) rather than constructing a second instance.
cfg.CSRF.ExcludePaths = []string{"/csrf/token"}
// Routes: register the refresh handler off the framework-built v.CSRF.
v.Routes(func(r *velocity.Routing) {
csrfInstance := v.CSRF.(*csrf.CSRF)
handler := csrfInstance.RefreshHandler()
r.Web(func(web router.Router) {
web.Get("/csrf/token", func(c *router.Context) error {
handler(c.Response, c.Request)
return nil
})
})
})JavaScript Integration
// Fetch token on page load
async function getCSRFToken() {
const response = await fetch('/csrf/token');
const data = await response.json();
return data.token;
}
// Use in AJAX requests
async function submitForm(formData) {
const token = await getCSRFToken();
const response = await fetch('/api/submit', {
method: 'POST',
headers: {
'X-CSRF-Token': token,
'Content-Type': 'application/json'
},
body: JSON.stringify(formData)
});
return response.json();
}
// Or store in meta tag and reuse
const token = document.querySelector('meta[name="csrf-token"]').content;
fetch('/api/data', {
method: 'POST',
headers: {
'X-CSRF-Token': token,
'Content-Type': 'application/json'
},
body: JSON.stringify(data)
});Axios Integration
// Set default header for all requests
const token = document.querySelector('meta[name="csrf-token"]').content;
axios.defaults.headers.common['X-CSRF-Token'] = token;
// Now all POST/PUT/DELETE requests include the token
axios.post('/api/submit', data);Error Handling
Default Error Response
HTML Requests:
HTTP/1.1 419 Authentication Timeout
Content-Type: text/plain
CSRF token validation failed. Please refresh and try again.JSON Requests:
{
"code": 419,
"message": "CSRF token validation failed. Please refresh and try again."
}A request is treated as JSON when its Content-Type or Accept header
contains application/json.
Custom Error Handler
config.ErrorHandler = func(w http.ResponseWriter, r *http.Request, err error) {
// Log the error
log.Warn("CSRF validation failed",
"error", err,
"ip", r.RemoteAddr,
"path", r.URL.Path,
)
// Check if it's a JSON request
if strings.Contains(r.Header.Get("Accept"), "application/json") {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(419)
json.NewEncoder(w).Encode(map[string]interface{}{
"error": "csrf_validation_failed",
"message": "Your session has expired. Please refresh the page.",
})
return
}
// Render custom error page for HTML requests
w.WriteHeader(419)
template.Must(template.ParseFiles("views/errors/csrf.html")).Execute(w, nil)
}Security Considerations
HTTP Status Code 419
Velocity uses status code 419 Authentication Timeout for CSRF failures. This distinguishes CSRF errors from other validation errors.
SameSite Cookie Attribute
// Strict: Maximum CSRF protection, may break cross-site navigation
config.SameSite = http.SameSiteStrictMode
// Lax: Balanced protection (default, recommended)
config.SameSite = http.SameSiteLaxMode
// None: Minimal protection, requires Secure=true
config.SameSite = http.SameSiteNoneModeSingle-Use Tokens
// Enable single-use tokens for maximum security
config.SingleUse = true
// Note: Requires token refresh after each request
// Best for high-security operationsWhen SingleUse is enabled the XSRF-TOKEN convenience cookie is not
written (the value would be consumed by the next unsafe request and go
stale). Use RefreshHandler to hand out a new token after each request.
Token Lifetime
// Short lifetime for high-security applications
config.TokenLifetime = 1 * time.Hour
// Longer lifetime for better UX
config.TokenLifetime = 24 * time.HourSession lifecycle
*csrf.CSRF implements contract.CSRFTokenRotator, which the auth
subsystem uses to keep tokens aligned with the session:
RotateToken(oldID, newID)- delete the old session’s token and mint a fresh one for the new ID (called after session regeneration on login).RevokeToken(id)- delete a session’s token (called on logout).WriteXSRFCookie(w, sessionID)/ClearXSRFCookie(w, r)- write or clear theXSRF-TOKENcookie so SPA clients pick up the rotated token.
Release the token store’s background goroutine on shutdown with
(*csrf.CSRF).Shutdown(ctx); velocity.New registers this for you.
Events
When the middleware cannot resolve a session on an unsafe request it
dispatches a csrf.session_fallback event (csrf.SessionFallback).
Frequent occurrences usually mean the session middleware is not running
ahead of CSRF, or SessionCookieName does not match your session cookie.
Best Practices
- Always Use CSRF for State-Changing Operations: Protect POST, PUT, DELETE, PATCH requests
- Exclude Read-Only Operations: GET, HEAD, OPTIONS don’t need CSRF protection
- Use HTTPS in Production: Set
Secure: trueto prevent token interception - Implement Token Refresh: Provide
/csrf/tokenendpoint for SPAs - Set Appropriate SameSite: Use
LaxorStrictbased on your needs - Monitor CSRF Failures: Log failures to detect potential attacks
- Handle Expired Tokens Gracefully: Show user-friendly error messages
Testing
func TestCSRFProtection(t *testing.T) {
config := csrf.DefaultConfig()
// SessionIDResolver is required; key tokens by the session_id cookie.
config.SessionIDResolver = func(r *http.Request) (string, error) {
if c, err := r.Cookie("session_id"); err == nil && c.Value != "" {
return c.Value, nil
}
return "", csrf.ErrNoSession
}
protection := csrf.New(config)
mw := protection.Middleware
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
handler := mw(next)
// POST without token → 419
req := httptest.NewRequest(http.MethodPost, "/submit", nil)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
assert.Equal(t, 419, rec.Code)
// POST with valid token → 200
sessionID := "test-session"
token, _ := protection.GetToken(sessionID)
req = httptest.NewRequest(http.MethodPost, "/submit", nil)
req.Header.Set("X-CSRF-Token", token)
req.AddCookie(&http.Cookie{Name: "session_id", Value: sessionID})
rec = httptest.NewRecorder()
handler.ServeHTTP(rec, req)
assert.Equal(t, http.StatusOK, rec.Code)
// GET → passes (safe method)
req = httptest.NewRequest(http.MethodGet, "/page", nil)
rec = httptest.NewRecorder()
handler.ServeHTTP(rec, req)
assert.Equal(t, http.StatusOK, rec.Code)
}Middleware takes an http.Handler (net/http compatible). For the
router-compatible variant, call RouterMiddleware() - see the
testing docs for end-to-end tests through the
full Velocity middleware chain.
Troubleshooting
Token Validation Always Failing
Problem: CSRF validation fails even with valid tokens
Solutions:
- Verify session cookie is being sent
- Check
SessionCookieNamematches your session cookie - Ensure cookies are not blocked by browser
- Verify HTTPS if
Secure: trueis set
Tokens Not Reaching Templates
Problem: {{ .csrfToken }} renders empty
Solutions:
- Verify the shared-props function (
engine.SetSharePropsFunc) is publishingcsrf.TokenForRequest(r)- see Template integration - Confirm the CSRF middleware runs before the handler that renders the
page;
TokenForRequestreturnscsrf.ErrNoTokenStateotherwise - Confirm the session cookie is being sent with the request (an anonymous request has no token to embed yet)
AJAX Requests Failing
Problem: AJAX POST/PUT/DELETE returns 419
Solutions:
- Include token in
X-CSRF-Tokenheader - Ensure token is fetched from meta tag or API
- Check token hasn’t expired
- Verify content-type header is set correctly