CSRF protection
In this chapter we’ll look at how to protect our application from cross-site request forgery (CSRF) attacks.
If you’re not familiar with the principles of CSRF, it’s a type of attack where a malicious third-party website sends state-changing HTTP requests to your website. A great explanation of the basic CSRF attack can be found here.
In our application, the main risk is this:
A user logs into our application. Our session cookie is set to persist for 12 hours, so they will remain logged in even if they navigate away from the application.
The user then goes to another website, which contains some malicious code that sends a cross-site request to our
POST /snippet/create
endpoint to add a new snippet to our database. The user’s session cookie for our application will be sent along with this request.Because the request includes the session cookie, our application will interpret the request as coming from a logged-in user and it will process the request with that user’s privileges. So completely unknown to the user, a new snippet will be added to our database.
As well as ‘traditional’ CSRF attacks like the above (where a request is processed with a logged-in user’s privileges) your application may also be at risk from login and logout CSRF attacks.
SameSite cookies
One mitigation that we can take to prevent CSRF attacks is to make sure that the SameSite
attribute is appropriately set on our session cookie.
By default the alexedwards/scs
package that we’re using always sets SameSite=Lax
on the session cookie. This means that the session cookie won’t be sent by the user’s browser for any cross-site requests with the HTTP methods POST
, PUT
or DELETE
.
So long as our application uses the POST
method for any state-changing HTTP requests (like we are for our login, signup, logout and create snippet form submissions), it means that the session cookie won’t be sent for these requests if they come from another website — thereby preventing the CSRF attack.
However, the SameSite
attribute is still relatively new and only fully supported by 96% of browsers worldwide. So, although it’s something that we can (and should) use as a defensive measure, we can’t rely on it for all users.
Token-based mitigation
To mitigate the risk of CSRF for all users we’ll also need to implement some form of token check. Like session management and password hashing, when it comes to this there’s a lot that you can get wrong… so it’s probably safest to use a tried-and-tested third-party package instead of rolling your own implementation.
The two most popular packages for stopping CSRF attacks in Go web applications are gorilla/csrf
and justinas/nosurf
. They both do roughly the same thing, using the double-submit cookie pattern to prevent attacks. In this pattern a random CSRF token is generated and sent to the user in a CSRF cookie. This CSRF token is then added to a hidden field in each HTML form that is potentially vulnerable to CSRF. When the form is submitted, both packages use some middleware to check that the hidden field value and cookie value match.
Out of the two packages, we’ll opt to use justinas/nosurf
in this book. I prefer it primarily because it’s self-contained and doesn’t have any additional dependencies. If you’re following along, you can install the latest version like so:
$ go get github.com/justinas/nosurf@v1 go: downloading github.com/justinas/nosurf v1.1.1 go get: added github.com/justinas/nosurf v1.1.1
Using the nosurf package
To use justinas/nosurf
, open up your cmd/web/middleware.go
file and create a new noSurf()
middleware function like so:
package main import ( "fmt" "net/http" "github.com/justinas/nosurf" // New import ) ... // Create a NoSurf middleware function which uses a customized CSRF cookie with // the Secure, Path and HttpOnly attributes set. func noSurf(next http.Handler) http.Handler { csrfHandler := nosurf.New(next) csrfHandler.SetBaseCookie(http.Cookie{ HttpOnly: true, Path: "/", Secure: true, }) return csrfHandler }
One of the forms that we need to protect from CSRF attacks is our logout form, which is included in our nav.tmpl
partial and could potentially appear on any page of our application. So, because of this, we need to use our noSurf()
middleware on all of our application routes (apart from GET /static/
).
So, let’s update the cmd/web/routes.go
file to add this noSurf()
middleware to the dynamic
middleware chain that we made earlier:
package main ... func (app *application) routes() http.Handler { mux := http.NewServeMux() fileServer := http.FileServer(http.Dir("./ui/static/")) mux.Handle("GET /static/", http.StripPrefix("/static", fileServer)) // Use the nosurf middleware on all our 'dynamic' routes. dynamic := alice.New(app.sessionManager.LoadAndSave, noSurf) mux.Handle("GET /{$}", dynamic.ThenFunc(app.home)) mux.Handle("GET /snippet/view/{id}", dynamic.ThenFunc(app.snippetView)) mux.Handle("GET /user/signup", dynamic.ThenFunc(app.userSignup)) mux.Handle("POST /user/signup", dynamic.ThenFunc(app.userSignupPost)) mux.Handle("GET /user/login", dynamic.ThenFunc(app.userLogin)) mux.Handle("POST /user/login", dynamic.ThenFunc(app.userLoginPost)) protected := dynamic.Append(app.requireAuthentication) mux.Handle("GET /snippet/create", protected.ThenFunc(app.snippetCreate)) mux.Handle("POST /snippet/create", protected.ThenFunc(app.snippetCreatePost)) mux.Handle("POST /user/logout", protected.ThenFunc(app.userLogoutPost)) standard := alice.New(app.recoverPanic, app.logRequest, commonHeaders) return standard.Then(mux) }
At this point, you might like to fire up the application and try submitting one of the forms. When you do, the request should be intercepted by the noSurf()
middleware and you should receive a 400 Bad Request
response.

To make the form submissions work, we need to use the nosurf.Token()
function to get the CSRF token and add it to a hidden csrf_token
field in each of our forms. So the next step is to add a new CSRFToken
field to our templateData
struct:
package main import ( "html/template" "path/filepath" "time" "snippetbox.alexedwards.net/internal/models" ) type templateData struct { CurrentYear int Snippet models.Snippet Snippets []models.Snippet Form any Flash string IsAuthenticated bool CSRFToken string // Add a CSRFToken field. } ...
And because the logout form can potentially appear on every page, it makes sense to add the CSRF token to the template data automatically via our newTemplateData()
helper. This will mean that it will be available to our templates each time we render a page.
Please go ahead and update the cmd/web/helpers.go
file as follows:
package main import ( "bytes" "errors" "fmt" "net/http" "time" "github.com/go-playground/form/v4" "github.com/justinas/nosurf" // New import ) ... func (app *application) newTemplateData(r *http.Request) templateData { return templateData{ CurrentYear: time.Now().Year(), Flash: app.sessionManager.PopString(r.Context(), "flash"), IsAuthenticated: app.isAuthenticated(r), CSRFToken: nosurf.Token(r), // Add the CSRF token. } } ...
Finally, we need to update all the forms in our application to include this CSRF token in a hidden field.
Like so:
{{define "title"}}Create a New Snippet{{end}} {{define "main"}} <form action='/snippet/create' method='POST'> <!-- Include the CSRF token --> <input type='hidden' name='csrf_token' value='{{.CSRFToken}}'> <div> <label>Title:</label> {{with .Form.FieldErrors.title}} <label class='error'>{{.}}</label> {{end}} <input type='text' name='title' value='{{.Form.Title}}'> </div> <div> <label>Content:</label> {{with .Form.FieldErrors.content}} <label class='error'>{{.}}</label> {{end}} <textarea name='content'>{{.Form.Content}}</textarea> </div> <div> <label>Delete in:</label> {{with .Form.FieldErrors.expires}} <label class='error'>{{.}}</label> {{end}} <input type='radio' name='expires' value='365' {{if (eq .Form.Expires 365)}}checked{{end}}> One Year <input type='radio' name='expires' value='7' {{if (eq .Form.Expires 7)}}checked{{end}}> One Week <input type='radio' name='expires' value='1' {{if (eq .Form.Expires 1)}}checked{{end}}> One Day </div> <div> <input type='submit' value='Publish snippet'> </div> </form> {{end}}
{{define "title"}}Login{{end}} {{define "main"}} <form action='/user/login' method='POST' novalidate> <!-- Include the CSRF token --> <input type='hidden' name='csrf_token' value='{{.CSRFToken}}'> {{range .Form.NonFieldErrors}} <div class='error'>{{.}}</div> {{end}} <div> <label>Email:</label> {{with .Form.FieldErrors.email}} <label class='error'>{{.}}</label> {{end}} <input type='email' name='email' value='{{.Form.Email}}'> </div> <div> <label>Password:</label> {{with .Form.FieldErrors.password}} <label class='error'>{{.}}</label> {{end}} <input type='password' name='password'> </div> <div> <input type='submit' value='Login'> </div> </form> {{end}}
{{define "title"}}Signup{{end}} {{define "main"}} <form action='/user/signup' method='POST' novalidate> <!-- Include the CSRF token --> <input type='hidden' name='csrf_token' value='{{.CSRFToken}}'> <div> <label>Name:</label> {{with .Form.FieldErrors.name}} <label class='error'>{{.}}</label> {{end}} <input type='text' name='name' value='{{.Form.Name}}'> </div> <div> <label>Email:</label> {{with .Form.FieldErrors.email}} <label class='error'>{{.}}</label> {{end}} <input type='email' name='email' value='{{.Form.Email}}'> </div> <div> <label>Password:</label> {{with .Form.FieldErrors.password}} <label class='error'>{{.}}</label> {{end}} <input type='password' name='password'> </div> <div> <input type='submit' value='Signup'> </div> </form> {{end}}
{{define "nav"}} <nav> <div> <a href='/'>Home</a> {{if .IsAuthenticated}} <a href='/snippet/create'>Create snippet</a> {{end}} </div> <div> {{if .IsAuthenticated}} <form action='/user/logout' method='POST'> <!-- Include the CSRF token --> <input type='hidden' name='csrf_token' value='{{.CSRFToken}}'> <button>Logout</button> </form> {{else}} <a href='/user/signup'>Signup</a> <a href='/user/login'>Login</a> {{end}} </div> </nav> {{end}}
Go ahead and run the application again, then view source of one of the forms. You should see that it now has a CSRF token included in a hidden field, like so.

And if you try submitting the forms, it should now work correctly again.
Additional information
SameSite ‘Strict’ setting
If you want, you can change the session cookie to use the SameSite=Strict
setting instead of (the default) SameSite=Lax
. Like this:
sessionManager := scs.New() sessionManager.Cookie.SameSite = http.SameSiteStrictMode
But it’s important to be aware that using SameSite=Strict
will block the session cookie being sent by the user’s browser for all cross-site usage — including safe requests with HTTP methods like GET
and HEAD
.
While that might sound even safer (and it is!) the downside is that the session cookie won’t be sent when a user clicks on a link to your application from another website. In turn, that means that your application would initially treat the user as ‘not logged in’ even if they have an active session containing their "authenticatedUserID"
value.
So if your application will potentially have other websites linking to it (or links to it shared in emails or private messaging services), then SameSite=Lax
is generally the more appropriate setting.
SameSite cookies and TLS 1.3
Earlier in this chapter I said that we can’t solely rely on the SameSite
cookie attribute to prevent CSRF attacks, because it isn’t fully supported by all browsers.
But there is an exception to this rule, due to the fact that no browser exists which supports TLS 1.3 and does not support SameSite
cookies.
In other words, if you were to make TLS 1.3 the minimum supported version in the TLS config for your server, then all browsers able to use your application will support SameSite
cookies.
tlsConfig := &tls.Config{ MinVersion: tls.VersionTLS13, }
So long as you only allow HTTPS requests to your application and enforce TLS 1.3 as the minimum TLS version, you don’t need to make any additional mitigation against CSRF attacks (like using the justinas/nosurf
package). Just make sure that you always:
- Set
SameSite=Lax
orSameSite=Strict
on the session cookie; and - Use the
POST
,PUT
orDELETE
HTTP methods for any state-changing requests.