themsaid.com

Implementing Cross-Site Request Forgery (CSRF) Protection in Go Web Apps

2025-03-12

In a previous article, we discussed web sessions and explained how a session is created when a visitor first browses our web application. This session is then stored in a browser cookie and sent on every subsequent request, allowing the server to identify the visitor.

Now, imagine you are logged into GitHub and there's a session cookie in your browser that's sent with every request to GitHub. What would happen if you visit a website that has this hidden form?

<form action="https://github.com/repo/transfer" method="post">
	<input type="hidden" name="repo" value="you/your-repo">
	<input type="hidden" name="receiver" value="me">
</form>

<script>
	document.forms[0].submit();
</script>

The JavaScript code here submits a form that sends a POST request to the github.com/repo/transfer endpoint with two hidden fields: repo and receiver. When the form is submitted, the browser detects an existing cookie for the github.com domain and includes it in the request. This triggers a transfer operation, moving one of your repositories to the attacker's account.

This type of attack is known as Cross-Site Request Forgery (CSRF or XSRF for short), and it can take many forms beyond the example described above. In general, a CSRF attack occurs when an attacker tricks a user into making an unintended request to a trusted website where they are already authenticated. Because the user's session cookie is automatically included in the request, the website processes it as if it were a legitimate action initiated by the user.

Secure Cookies Alone Can't Stop CSRF Attacks

A secure cookie is one that has the following attributes:

cookie := &http.Cookie{
	HttpOnly: true,
	Secure:   true,
	SameSite: http.SameSiteLaxMode,
}

Setting HttpOnly: true prevents JavaScript from accessing the cookie, Secure: true ensures it is only sent over HTTPS, and SameSite: http.SameSiteLaxMode restricts cross-site cookie transmission.

You might assume that setting the SameSite=Lax cookie attribute is sufficient to prevent cross-site requests. However, this is not always the case. Older browser versions do not consistently enforce this attribute, leaving them vulnerable to Cross-Site Request Forgery attacks. Additionally, clickjacking presents another risk. In this type of attack, an attacker embeds your application inside an invisible iframe and tricks users into clicking buttons or performing actions they didn’t intend. Since these interactions originate from the same domain, they bypass SameSite restrictions, allowing the attacker to execute unauthorized actions on behalf of the user.

According to the Internet Engineering Task Force (IETF):

Lax enforcement provides reasonable defense in depth against CSRF attacks that rely on unsafe HTTP methods (like POST), but does not offer a robust defense against CSRF as a general category of attack. When possible, developers should use a session management mechanism to mitigate the risk of CSRF more completely.

This emphasizes that while SameSite=Lax provides some protection against CSRF attacks, it is not a complete solution. To fully mitigate the risk of CSRF, we should implement additional session management techniques, with the most common being the use of CSRF tokens.

CSRF Tokens

The concept behind CSRF tokens is to establish a shared secret token between the client and the server. When a user first starts a session, the server generates a unique CSRF token and stores it alongside the session data. The server then sends this token to the client in the response. Each time the client makes a POST, PUT, DELETE, or PATCH request, it must include the CSRF token, typically within the body of the request or in a custom header, which the server verifies by comparing it to the one stored in the token.

Now, consider the CSRF attack example we shared earlier:

<form action="https://github.com/repo/transfer" method="post">
	<input type="hidden" name="repo" value="you/your-repo">
	<input type="hidden" name="receiver" value="me">
</form>

<script>
	document.forms[0].submit();
</script>

When this form is submitted, the server will detect the missing CSRF token in the body and block the request. If the attacker includes an invalid token, the server will still reject the request upon recognizing that the token doesn't match the session's expected token.

In our application HTML template, we may share the token in the response in the form of a hidden field:

<form action="/repo/transfer" method="post">
	/** CSRF token field **/
	<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">

	<input type="text" name="repo">
	<input type="text" name="receiver">

	<input type="submit" />
</form>

When this form is submitted, the server will validate the CSRF token and allow the request.

Alternatively, if we use a JavaScript single page application (SPA), the server may share the CSRF token in a <meta> tag:

<meta name="csrf-token" content="{{.CSRFToken}}">

The client reads it and includes it as a header when submitting a request:

var csrf_token = document.querySelector("meta[name='csrf-token']").getAttribute("content");

axios.post('/repo/transfer', formData, {
	headers: {
		'X-XSRF-Token': csrf_token
	}
});

Finally, some popular JavaScript libraries (like AngularJS and Axios) read the CSRF token from a cookie named XSRF-TOKEN. If we include this cookie in the response, the library will read it and send its value back on every request in the form of a X-XSRF-TOKEN header.

Adding CSRF Protection to the Session Manager

Building on our previous implementation of a secure session manager in Go, we will now add CSRF protection to the session manager. This involves generating CSRF tokens, storing them in the session, and ensuring they are included with every state-changing request (POST, PUT, DELETE, and PATCH).

To generate a CSRF token, we will add a function that creates a 42-character base64 string with 256 bits of randomness:

func generateCSRFToken() string {
	id := make([]byte, 32)

	_, err := io.ReadFull(rand.Reader, id)
	if err != nil {
		panic("failed to generate CSRF token")
	}

	return base64.RawURLEncoding.EncodeToString(id)
}

Then, we'll ensure that a fresh CSRF token is created with every new session:

func newSession() *Session {
	return &Session{
		id:             generateSessionId(),
		data:           map[string]any{"csrf_token": generateCSRFToken()},
		createdAt:      time.Now(),
		lastActivityAt: time.Now(),
	}
}

Validating the CSRF Token

We will add a method to the session manager that extracts the CSRF token from a given session and validates it against the csrf_token form value or the X-CSRF-Token header:

func (m *SessionManager) verifyCSRFToken(r *http.Request, session *Session) bool {
	sToken, ok := session.Get("csrf_token").(string)
	if !ok {
		return false
	}

	token := r.FormValue("csrf_token")

	if token == "" {
		token = r.Header.Get("X-XSRF-Token")
	}

	return token == sToken
}

It's important to note that the form field name (csrf_token) and header name (X-XSRF-Token) are not fixed and can be customized. Here, I'm using common naming conventions for convenience. However, in your application, you may choose unique, less predictable names to add an extra layer of security.

Performing the Validation

In our session manager's middleware, we will call the verifyCSRFToken method in the beginning of state-changing requests (POST, PUT, PATCH & DELETE) and fail the request if the token doesn't match what's in the session:

if r.Method == http.MethodPost ||
	r.Method == http.MethodPut ||
	r.Method == http.MethodPatch ||
	r.Method == http.MethodDelete {

	if !m.verifyCSRFToken(rws, session) {
		http.Error(sw, "CSRF token mismatch", http.StatusForbidden)
		return
	}

}

next.ServeHTTP(sw, rws)

With this setup, any state-changing request will be blocked before reaching the next handler in the chain, preventing an attacker from executing critical business logic.

The full code of the Handle method now looks like this:

func (m *SessionManager) Handle(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		session, rws := m.start(r)

		sw := &sessionResponseWriter{
			ResponseWriter: w,
			sessionManager: m,
			request:        rws,
		}

		w.Header().Add("Vary", "Cookie")
		w.Header().Add("Cache-Control", `no-cache="Set-Cookie"`)

		if r.Method == http.MethodPost ||
			r.Method == http.MethodPut ||
			r.Method == http.MethodPatch ||
			r.Method == http.MethodDelete {
			if !m.verifyCSRFToken(rws, session) {
				http.Error(sw, "CSRF token mismatch", http.StatusForbidden)
				return
			}
		}

		next.ServeHTTP(sw, rws)

		m.save(session)

		writeCookieIfNecessary(sw)
	})
}

Using CSRF in the Web Application

As we mentioned earlier, there are multiple ways to pass the CSRF token in the application responses:

  1. As a hidden form field.
  2. As a meta tag content.
  3. As a cookie.

If the frontend of our application submits typical HTML forms, we may pass the CSRF token down to the template as a template data parameter:

session := auth.GetSession(r)

csrfToken := session.Get("csrf_token").(string)

data := map[string]string{
	"CSRFToken": csrfToken,
}

tmpl.Execute(w, data)

Then, inside our form, we add a hidden field that includes the CSRF token:

<form action="/repo/transfer" method="post">
	<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">

	<input type="submit" />
</form>

In the same way, we may include the CSRF token in a meta tag and read the value via JavaScript if the frontend uses JavaScript to submit forms:

<meta name="csrf-token" content="{{.CSRFToken}}">
var csrf_token = document.querySelector("meta[name='csrf-token']").getAttribute("content");

axios.post('/repo/transfer', formData, {
	headers: {
		'X-XSRF-Token': csrf_token
	}
});

Finally, if we're using a JavaScript library that supports the XSRF-TOKEN cookie, we may send that cookie in the handler that presents the SPA view to the browser:

session := auth.GetSession(r)

csrfToken := session.Get("csrf_token").(string)

cookie := &http.Cookie{
	Name:     "XSRF-TOKEN",
	Value:    csrfToken,
	Domain:   "domain.com",
	HttpOnly: true,
	Path:     "/",
	Secure:   true,
	SameSite: http.SameSiteLaxMode,
}

http.SetCookie(w, cookie)

Wrapping Up

Storing the session ID in a secure cookie, which browsers only send for requests originating from the same domain, is an important security measure but is not enough to fully protect users from request forgery. To strengthen security, the token validation pattern presented in this article adds a crucial layer of protection.

By incorporating CSRF tokens into session management, we ensure that each request made to the server is not only coming from a trusted origin but also includes a valid token that proves the request was intentionally generated by the user. This pattern helps prevent malicious actors from exploiting session cookies to perform unauthorized actions on behalf of the user.

Hi, I'm Mohamed Said.

I'm a software engineer, writer, and open-source contributor. You can reach me on Twitter/X @themsaid. Or send me an email at [email protected].