Let's Go User authentication › CSRF protection
Previous · Contents · Next
Chapter 10.7.

CSRF protection

In this chapter we’ll look at how to mitigate the risk of CSRF (cross-site request forgery) attacks — a type of attack in which a malicious third-party website sends state-changing HTTP requests to your application.

In our application right now, the main CSRF risk looks like this:

There are a variety of things we can do to prevent CSRF attacks, including setting the SameSite=Lax attribute on session cookies, using the http.CrossOriginProtection middleware in the Go standard library, and using a third-party package to implement a CSRF token check.

Let’s go through them one-by-one.

Using SameSite cookies

One big mitigation we can take against CSRF is to make sure that the SameSite=Lax attribute is set on our session cookie. Whenever this attribute is set, the cookie won’t be sent by the user’s browser for any cross-site requests with the HTTP method POST.

By default, the alexedwards/scs package that we’re using for sessions automatically sets SameSite=Lax on the session cookie, so we’re already in a good place here. So long as we only ever change our application state in response to POST requests — never GET requests — our application will generally be safe from CSRF attacks.

However, the SameSite attribute is only fully supported by 95% of browsers worldwide. So, although it’s something that we can (and should) use as a defensive measure, we can’t completely rely on it. And there is another potential issue — setting SameSite=Lax is not enough to prevent a CSRF attack taking place from a compromised subdomain of your main application.

The http.CrossOriginProtection middleware

Go 1.25 includes a new http.CrossOriginProtection middleware type as part of the standard library. This middleware works by checking the values in the Sec-Fetch-Site and Origin request headers to determine if a request is coming from a different origin or not, and it will automatically reject any cross-origin POST requests and send the client a 403 Forbidden response.

While the http.CrossOriginProtection middleware is a great addition to the standard library, it’s important to be aware that it will not provide protection for requests from older (pre-2020) browsers that do not set at least one of the Sec-Fetch-Site or Origin headers. Right now, the Sec-Fetch-Site header is supported by 92% of browsers worldwide, and the Origin header is at 95%.

Token-based mitigation

The missing browser support for SameSite cookies and the Sec-Fetch-Site and Origin headers means that if we want to mitigate the risk of CSRF for all users, we have to fall back to implementing some form of CSRF 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. 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, and it’s also a bit more actively maintained. 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.2.0
go get: added github.com/justinas/nosurf v1.2.0

Using the nosurf package

To use justinas/nosurf, open up your cmd/web/middleware.go file and create a new preventCSRF() middleware function like so:

File: cmd/web/middleware.go
package main

import (
    "fmt"
    "net/http"

    "github.com/justinas/nosurf" // New import
)

...

// Create a preventCSRF middleware function which uses a customized CSRF cookie with
// the Secure, Path and HttpOnly attributes set.
func preventCSRF(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 preventCSRF() middleware on all of our application routes (apart from GET /static/).

So, let’s update the cmd/web/routes.go file to add this preventCSRF() middleware to the dynamic middleware chain that we made earlier:

File: cmd/web/routes.go
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, preventCSRF)

    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 preventCSRF() middleware and you should receive a 400 Bad Request response.

10.07-01.png

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:

File: cmd/web/templates.go
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:

File: cmd/web/helpers.go
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:

File: ui/html/pages/create.tmpl
{{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}}
File: ui/html/pages/login.tmpl
{{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}}
File: ui/html/pages/signup.tmpl
{{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}}
File: ui/html/partials/nav.tmpl
{{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 includes a CSRF token included in a hidden field, like so.

10.07-02.png

And if you try submitting the forms, it should now work correctly again.


Additional information

A modern approach to preventing CSRF

Earlier in this chapter, I said that we can’t only rely on the SameSite=Lax 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 all browsers which support TLS 1.3 also support SameSite cookies.

So, if you set TLS 1.3 the minimum supported version in the TLS config for your server, you can be confident that any browser using your application will support SameSite cookies, and setting SameSite=Lax on the session cookie is enough to prevent cross-site (but not necessarily cross-origin) CSRF attacks.

tlsConfig := &tls.Config{
    MinVersion: tls.VersionTLS13,
}

As a defense-in-depth measure, and to mitigate the risk of a cross-origin CSRF attack from a subdomain, it is a good idea to also use the http.CrossOriginProtection functionality in the standard library. At its simplest, that would mean updating your preventCSRF middleware to look like this:

func preventCSRF(next http.Handler) http.Handler {
	cop := http.NewCrossOriginProtection()
	return cop.Handler(next)
}

If you want, you can also configure it to allow POST requests from other specific trusted origins, or change the default error response like so:

func (app *application) preventCSRF(next http.Handler) http.Handler {
	cop := http.NewCrossOriginProtection()

	cop.AddTrustedOrigin("https://foo.example.com")

	cop.SetDenyHandler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		w.WriteHeader(http.StatusBadRequest)
		w.Write([]byte("CSRF check failed"))
	}))

	return cop.Handler(next)
}

This combination of three things — enforcing the use of TLS 1.3, using SameSite=Lax on your session cookie, and the http.CrossOriginProtection middleware is a pretty robust approach to preventing CSRF attacks.

It’s not 100% perfect though. There is a small group of old web browsers (including Firefox versions 63-69) that support TLS 1.3 but don’t include the Sec-Fetch-Site or Origin header in POST requests. For those browsers, the http.CrossOriginProtection middleware won’t work, meaning that there remains a risk of a CSRF attack from a compromised subdomain.

But if you don’t have any subdomains, and are happy to enforce TLS 1.3, this modern approach to preventing CSRF is a good option, and much cleaner and simpler than using nosurf and adding CSRF tokens to all your forms.

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 from 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 will 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.