Not sure how to structure your Go web application?
My new book guides you through the start-to-finish build of a real world web application in Go — covering topics like how to structure your code, manage dependencies, create dynamic database-driven pages, and how to authenticate and authorize users securely.
In this post we're going to run through how to use cookies in your Go web application to persist data between HTTP requests for a specific client. We'll start simple, and slowly build up a working application which covers the following topics:
If you just want the final code, rather than the explanations, you can find it in this gist.
Basic use
The first thing to know is that cookies in Go are represented by the http.Cookie type. This is a struct which looks like this:
Name is the cookie name. It can contain any US-ASCII charactersexcept( ) < > @ , ; : \ " / [ ? ] = { } and space, tab and control characters. It is a mandatory field.
Value contains the data that you want to persist. It can contain any US-ASCII characters except, ; \ " and space, tab and control characters. It is a mandatory field.
Path, Domain, Expires, MaxAge, Secure, HttpOnly and SameSite map directly to the respective cookie attributes. All of these are optional fields.
If set, the value of the SameSite field should be one of the SameSite constants from the net/http package.
The RawExpires, Raw and Unparsed fields are only used when your Go program is acting as a client (rather than a server) and parsing the cookies from a HTTP response. Most of the time you won't need to use these fields.
Cookies can be written in a HTTP response using the http.SetCookie() function, and read from a HTTP request using the *Request.Cookie() method.
Let's jump in and use these things in a working example.
If you'd like to follow along, please run the following commands to set up a basic project scaffold:
In the main.go file we're going to create a simple web application with two endpoints:
GET /set which writes a new cookie along with the HTTP response.
GET /get which reads the cookie sent with the HTTP request and then echoes out the cookie value in the response.
Go ahead and add the following code to main.go:
OK, let's try this out. Go ahead and run the application:
And then open http://localhost:3000/set in your web browser. You should see the "cookie set!" response and, if you have developer tools open, you should also see the Set-Cookie header containing the data in the HTTP response headers.
Then if you visit http://localhost:3000/get, our exampleCookie cookie should be passed back along with the HTTP request, and our getCookieHandler will retrieve the cookie value and print it in the response. Like so:
If you want, you can also make a request to http://localhost:3000/set using curl to see the contents of the Set-Cookie header. Like so:
Encoding special characters and maximum length
So far, so good! But there are a couple of important things to be aware of when writing cookies.
As mentioned briefly above, cookie values must only contain a subset of the US-ASCII characters. If you try to use an unsupported character, Go will strip it out before setting the Set-Cookie header.
Let's try this out by adapting our setCookieHandler to write a cookie value containing a non US-ASCII character like "Hello Zoë!" (notice the umlauted ë character):
Then when you make a request to http://localhost:3000/set, you'll see that the cookie value has been stripped down to "Hello Zo!".
A good way to avoid this kind of problem is to base64-encode your cookie values before writing them. Because the base64 character set is a subset of the US-ASCII characters supported in cookies, we can be confident that nothing will be stripped from the cookie value.
Another thing to be aware of is that web browsers impose a maximum size limit on cookies. But this limit — and how the cookie size is calculated — depends on the browser version being used. To prevent problems, a good rule-of-thumb is to keep the total size of the cookie (including all attributes) to no more than 4096 bytes.
If you try to send a cookie larger than 4096 bytes, Go will write the Set-Cookie header without any problems (it won't be truncated), but there is a risk that the client may truncate or reject the cookie.
To help with these two potential problems, let's create an internal/cookies package containing a couple of helper functions:
A Write() function which encodes a cookie value to base64 and checks that the total length of the cookie is no more than 4096 bytes before writing it.
A Read() function which reads a cookie from the current request and decodes the cookie value from base64.
Then we can update our main.go file to use these new helpers, like so:
If you restart your web application and make a request to http://localhost:3000/set followed by http://localhost:3000/get in your browser, you should now successfully see the message "Hello Zoë!" in full.
Likewise, if you make a request to http://localhost:3000/set using curl, you should see that the cookie value is SGVsbG8gWm_DqyE= — which is the base64 encoding of Hello Zoë!.
Tamper-proof (signed) cookies
By default, you shouldn't trust cookie data. Because cookies are stored on the client, it's fairly straightforward for a user to edit them (in fact, many web browser extensions exist for exactly this purpose). So if you're performing actions in your web application based on the value of a cookie, it's important to first verify that the cookie hasn't been edited and contains the original name and value that you set.
A good way to do this is to generate a HMAC signature of the cookie name and value, and then prepend this signature to the cookie value before sending it to the client. So that the final value is in this format:
cookie.Value = "{HMAC signature}{original value}"
When we receive the cookie back from the client, we can recalculate the HMAC signature from the cookie name and original value, and check that the recalculated HMAC signature matches the signature at the start of the received cookie. If they match, it confirms the integrity of the name and value — and we know that it hasn't been edited by the client.
Let's update the internal/cookies/cookies.go file to include some WriteSigned() and ReadSigned() functions which do exactly that.
Alright, let's update our main.go file to include a secret key and use the new WriteSigned() and ReadSigned() functions.
The secret key should be generated using a cryptographically secure random number generator (CSRNG), should be unique to your application, and should ideally have at least 32 bytes of entropy. For the purpose of this example, we'll use a random 64 character hex string and decode it to give us a byte slice containing 32 random bytes.
If you visit http://localhost:3000/set in your web browser followed by http://localhost:3000/get, you should still successfully see the message "Hello Zoë!".
If you like, you can also use a browser extension to change the cookie value (search for "cookie editor" in your browser extension store). If you do this and visit http://localhost:3000/get again, you should now receive a 400 Bad Request response and the "invalid cookie" message.
Before we move on, let's also make a request to http://localhost:3000/set using curl:
In my case we can see that the signed cookie value is:
The first part of the decoded value is the HMAC signature (which looks like gibberish), followed by our original cookie value in plaintext.
Confidential (encrypted) and tamper-proof cookies
The HMAC signing pattern above is great for times when you want to confirm that a cookie has not been edited by a client, and you're not worried about the client being able to read the cookie data (i.e. the cookie doesn't contain any secret or confidential information).
But if you do want to prevent the client from being able to read the cookie data, we need to encrypt the data before writing it.
A good way to encrypt the data in cookies is to use AES-GCM (AES with Galois/Counter Mode) encryption. AES-GCM is a type of authenticated encryption, which is good because it both encrypts and authenticates the data. The encryption ensures confidentiality of the data, and the authentication ensures the integrity of the data (i.e. that the data hasn't been changed). Effectively, encrypting our cookie data using AES-GCM is a relatively easy way to give us confidential, tamper-proof, cookies in a single step.
Let's create two new helper functions, WriteEncrypted() and ReadEncrypted(), which use this. Like so:
Then we can switch our main.go file to use these new helpers like so:
Again, you can visit http://localhost:3000/set and http://localhost:3000/get in your browser, and you should still successfully see the message "Hello Zoë!". And if you edit the exampleCookie cookie using a browser extension, you should find that any subsequent requests result in an "invalid cookie" response.
Let's take a look at the Set-Cookie header now using curl.
If we base64-decode this value, we should now just see gibberish and our original "Hello Zoë!" value should no longer be visible.
Great! The encryption has worked!
Storing custom data types
So far we've just been storing simple string data in our cookies. But what if we want to store something more complicated, like the data for a user represented as a struct in Go?
The good news is that the Go standard library includes the encoding/gob package, which we can use to encode/decode a Go value to and from a byte slice. It's kind of like "pickling" in Python, "marshaling" in Ruby, or "serializing" in PHP.
To help demonstrate how to use this, let's update our main.go file to gob-encode a User struct and store it in a cookie:
If you want, restart your application and visit localhost:3000/set followed by localhost:3000/get in your web browser. You should see a response similar to this:
If you enjoyed this article, you might like to check out my recommended tutorials list or check out my books Let's Go and Let's Go Further, which teach you everything you need to know about how to build professional production-ready web applications and APIs with Go.