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.

Take a look!

A complete guide to working with Cookies in Go

Published on:

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:

type Cookie struct {
    Name  string
    Value string

    Path       string    
    Domain     string    
    Expires    time.Time 
    RawExpires string   

    // MaxAge=0 means no 'Max-Age' attribute specified.
    // MaxAge<0 means delete cookie now, equivalently 'Max-Age: 0'
    // MaxAge>0 means Max-Age attribute present and given in seconds
    MaxAge   int 
    Secure   bool
    HttpOnly bool
    SameSite SameSite
    Raw      string
    Unparsed []string
}
  • Name is the cookie name. It can contain any US-ASCII characters except ( ) < > @ , ; : \ " / [ ? ] = { } 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:

$ mkdir example-project
$ cd example-project/
$ go mod init example.com/example-project
go: creating new go.mod: module example.com/example-project
$ touch main.go

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:

File: main.go
package main

import (
    "errors"
    "log"
    "net/http"
)

func main() {
    // Start a web server with the two endpoints.
    mux := http.NewServeMux()
    mux.HandleFunc("/set", setCookieHandler)
    mux.HandleFunc("/get", getCookieHandler)

    log.Print("Listening...")
    err := http.ListenAndServe(":3000", mux)
    if err != nil {
        log.Fatal(err)
    }
}

func setCookieHandler(w http.ResponseWriter, r *http.Request) {
    // Initialize a new cookie containing the string "Hello world!" and some
    // non-default attributes.
    cookie := http.Cookie{
        Name:     "exampleCookie",
        Value:    "Hello world!",
        Path:     "/",
        MaxAge:   3600,
        HttpOnly: true,
        Secure:   true,
        SameSite: http.SameSiteLaxMode,
    }

    // Use the http.SetCookie() function to send the cookie to the client.
    // Behind the scenes this adds a `Set-Cookie` header to the response
    // containing the necessary cookie data.
    http.SetCookie(w, &cookie)

    // Write a HTTP response as normal.
    w.Write([]byte("cookie set!"))
}

func getCookieHandler(w http.ResponseWriter, r *http.Request) {
    // Retrieve the cookie from the request using its name (which in our case is
    // "exampleCookie"). If no matching cookie is found, this will return a
    // http.ErrNoCookie error. We check for this, and return a 400 Bad Request
    // response to the client.
    cookie, err := r.Cookie("exampleCookie")
    if err != nil {
        switch {
        case errors.Is(err, http.ErrNoCookie):
            http.Error(w, "cookie not found", http.StatusBadRequest)
        default:
            log.Println(err)
            http.Error(w, "server error", http.StatusInternalServerError)
        }
        return
    }

    // Echo out the cookie value in the response body.
    w.Write([]byte(cookie.Value))
}

OK, let's try this out. Go ahead and run the application:

$ go run .
2022/09/25 10:44:11 Listening...

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:

$ curl -i http://localhost:3000/set
HTTP/1.1 200 OK
Set-Cookie: exampleCookie="Hello world!"; Path=/; Max-Age=3600; HttpOnly; Secure; SameSite=Lax
Date: Sun, 25 Sep 2022 08:45:02 GMT
Content-Length: 11
Content-Type: text/plain; charset=utf-8

cookie set!

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):

File: main.go
package main

...

func setCookieHandler(w http.ResponseWriter, r *http.Request) {
    cookie := http.Cookie{
        Name:     "exampleCookie",
        Value:    "Hello Zoë!",
        Path:     "/",
        MaxAge:   3600,
        HttpOnly: true,
        Secure:   true,
        SameSite: http.SameSiteLaxMode,
    }

    http.SetCookie(w, &cookie)

    w.Write([]byte("cookie set!"))
}

...

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!".

$ curl -i http://localhost:3000/set
HTTP/1.1 200 OK
Set-Cookie: exampleCookie="Hello Zo!"; Path=/; Max-Age=3600; HttpOnly; Secure; SameSite=Lax
Date: Sun, 25 Sep 2022 09:00:03 GMT
Content-Length: 11
Content-Type: text/plain; charset=utf-8

cookie set!

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.
$ mkdir -p internal/cookies
$ touch internal/cookies/cookies.go
File: internal/cookies/cookies.go
package cookies

import (
    "encoding/base64"
    "errors"
    "net/http"
)

var (
    ErrValueTooLong = errors.New("cookie value too long")
    ErrInvalidValue = errors.New("invalid cookie value")
)

func Write(w http.ResponseWriter, cookie http.Cookie) error {
    // Encode the cookie value using base64.
    cookie.Value = base64.URLEncoding.EncodeToString([]byte(cookie.Value))

    // Check the total length of the cookie contents. Return the ErrValueTooLong
    // error if it's more than 4096 bytes.
    if len(cookie.String()) > 4096 {
        return ErrValueTooLong
    }

    // Write the cookie as normal.
    http.SetCookie(w, &cookie)

    return nil
}

func Read(r *http.Request, name string) (string, error) {
    // Read the cookie as normal.
    cookie, err := r.Cookie(name)
    if err != nil {
        return "", err
    }

    // Decode the base64-encoded cookie value. If the cookie didn't contain a
    // valid base64-encoded value, this operation will fail and we return an
    // ErrInvalidValue error.
    value, err := base64.URLEncoding.DecodeString(cookie.Value)
    if err != nil {
        return "", ErrInvalidValue
    }

    // Return the decoded cookie value.
    return string(value), nil
}

Then we can update our main.go file to use these new helpers, like so:

File: main.go
package main

import (
    "errors"
    "log"
    "net/http"

    "example.com/example-project/internal/cookies" // Import the internal/cookies package.
)

...

func setCookieHandler(w http.ResponseWriter, r *http.Request) {
    // Initialize the cookie as normal.
    cookie := http.Cookie{
        Name:     "exampleCookie",
        Value:    "Hello Zoë!",
        Path:     "/",
        MaxAge:   3600,
        HttpOnly: true,
        Secure:   true,
        SameSite: http.SameSiteLaxMode,
    }

    // Write the cookie. If there is an error (due to an encoding failure or it
    // being too long) then log the error and send a 500 Internal Server Error
    // response.
    err := cookies.Write(w, cookie)
    if err != nil {
        log.Println(err)
        http.Error(w, "server error", http.StatusInternalServerError)
        return
    }

    w.Write([]byte("cookie set!"))
}

func getCookieHandler(w http.ResponseWriter, r *http.Request) {
    // Use the Read() function to retrieve the cookie value, additionally
    // checking for the ErrInvalidValue error and handling it as necessary.
    value, err := cookies.Read(r, "exampleCookie")
    if err != nil {
        switch {
        case errors.Is(err, http.ErrNoCookie):
            http.Error(w, "cookie not found", http.StatusBadRequest)
        case errors.Is(err, cookies.ErrInvalidValue):
            http.Error(w, "invalid cookie", http.StatusBadRequest)
        default:
            log.Println(err)
            http.Error(w, "server error", http.StatusInternalServerError)
        }
        return
    }

    w.Write([]byte(value))
}

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ë!.

$ curl -i localhost:3000/set
HTTP/1.1 200 OK
Set-Cookie: exampleCookie=SGVsbG8gWm_DqyE=; Path=/; Max-Age=3600; HttpOnly; Secure; SameSite=Lax
Date: Sun, 25 Sep 2022 09:14:18 GMT
Content-Length: 11
Content-Type: text/plain; charset=utf-8

cookie set
$ echo "SGVsbG8gWm_DqyE=" | base64url --decode
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.

File: internal/cookies/cookies.go
package cookies

import (
    "crypto/hmac"  
    "crypto/sha256"
    "encoding/base64"
    "errors"
    "net/http"
)

...

func WriteSigned(w http.ResponseWriter, cookie http.Cookie, secretKey []byte) error {
    // Calculate a HMAC signature of the cookie name and value, using SHA256 and
    // a secret key (which we will create in a moment).
    mac := hmac.New(sha256.New, secretKey)
    mac.Write([]byte(cookie.Name))
    mac.Write([]byte(cookie.Value))
    signature := mac.Sum(nil)

    // Prepend the cookie value with the HMAC signature.
    cookie.Value = string(signature) + cookie.Value

    // Call our Write() helper to base64-encode the new cookie value and write
    // the cookie.
    return Write(w, cookie)
}

func ReadSigned(r *http.Request, name string, secretKey []byte) (string, error) {
    // Read in the signed value from the cookie. This should be in the format
    // "{signature}{original value}".
    signedValue, err := Read(r, name)
    if err != nil {
        return "", err
    }

    // A SHA256 HMAC signature has a fixed length of 32 bytes. To avoid a potential
    // 'index out of range' panic in the next step, we need to check sure that the
    // length of the signed cookie value is at least this long. We'll use the 
    // sha256.Size constant here, rather than 32, just because it makes our code
    // a bit more understandable at a glance.
    if len(signedValue) < sha256.Size {
        return "", ErrInvalidValue
    }

    // Split apart the signature and original cookie value.
    signature := signedValue[:sha256.Size]
    value := signedValue[sha256.Size:]

    // Recalculate the HMAC signature of the cookie name and original value.
    mac := hmac.New(sha256.New, secretKey)
    mac.Write([]byte(name))
    mac.Write([]byte(value))
    expectedSignature := mac.Sum(nil)

    // Check that the recalculated signature matches the signature we received
    // in the cookie. If they match, we can be confident that the cookie name
    // and value haven't been edited by the client.
    if !hmac.Equal([]byte(signature), expectedSignature) {
        return "", ErrInvalidValue
    }

    // Return the original cookie value.
    return value, nil
}

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.

File: main.go
package main

import (
    "encoding/hex"
    "errors"
    "log"
    "net/http"

    "example.com/example-project/internal/cookies"
)

// Declare a global variable to hold the secret key.
var secretKey []byte

func main() {
    var err error

    // Decode the random 64-character hex string to give us a slice containing
    // 32 random bytes. For simplicity, I've hardcoded this hex string but in a
    // real application you should read it in at runtime from a command-line
    // flag or environment variable.
    secretKey, err = hex.DecodeString("13d6b4dff8f84a10851021ec8608f814570d562c92fe6b5ec4c9f595bcb3234b")
    if err != nil {
        log.Fatal(err)
    }

    mux := http.NewServeMux()
    mux.HandleFunc("/set", setCookieHandler)
    mux.HandleFunc("/get", getCookieHandler)

    log.Print("Listening...")
    err = http.ListenAndServe(":3000", mux)
    if err != nil {
        log.Fatal(err)
    }
}

func setCookieHandler(w http.ResponseWriter, r *http.Request) {
    cookie := http.Cookie{
        Name:     "exampleCookie",
        Value:    "Hello Zoë!",
        Path:     "/",
        MaxAge:   3600,
        HttpOnly: true,
        Secure:   true,
        SameSite: http.SameSiteLaxMode,
    }

    // Use the WriteSigned() function, passing in the secret key as the final
    // argument.
    err := cookies.WriteSigned(w, cookie, secretKey)
    if err != nil {
        log.Println(err)
        http.Error(w, "server error", http.StatusInternalServerError)
        return
    }

    w.Write([]byte("cookie set!"))
}

func getCookieHandler(w http.ResponseWriter, r *http.Request) {
    // Use the ReadSigned() function, passing in the secret key as the final
    // argument.
    value, err := cookies.ReadSigned(r, "exampleCookie", secretKey)
    if err != nil {
        switch {
        case errors.Is(err, http.ErrNoCookie):
            http.Error(w, "cookie not found", http.StatusBadRequest)
        case errors.Is(err, cookies.ErrInvalidValue):
            http.Error(w, "invalid cookie", http.StatusBadRequest)
        default:
            log.Println(err)
            http.Error(w, "server error", http.StatusInternalServerError)
        }
        return
    }

    w.Write([]byte(value))
}

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:

$ curl -i http://localhost:3000/set
HTTP/1.1 200 OK
Set-Cookie: exampleCookie=1lYrR9MfMsu6Dm39EgfbOuFTUbZm3_5tmWsF943HN4hIZWxsbyBab8OrIQ==; Path=/; Max-Age=3600; HttpOnly; Secure; SameSite=Lax
Date: Wed, 28 Sep 2022 09:28:55 GMT
Content-Length: 11
Content-Type: text/plain; charset=utf-8

cookie set!

In my case we can see that the signed cookie value is:

1lYrR9MfMsu6Dm39EgfbOuFTUbZm3_5tmWsF943HN4hIZWxsbyBab8OrIQ==

Let's base64-decode this:

$ echo "1lYrR9MfMsu6Dm39EgfbOuFTUbZm3_5tmWsF943HN4hIZWxsbyBab8OrIQ==" | base64url --decode
�V+G�2˺m��:�SQ�f��m�k���7�Hello Zoë!

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:

File: internal/cookies/cookies.go
package cookies

import (
    "crypto/aes"    
    "crypto/cipher" 
    "crypto/hmac"
    "crypto/rand"
    "crypto/sha256"
    "encoding/base64"
    "errors"
    "fmt" 
    "io" 
    "net/http"
    "strings" 
)

...

func WriteEncrypted(w http.ResponseWriter, cookie http.Cookie, secretKey []byte) error {
    // Create a new AES cipher block from the secret key.
    block, err := aes.NewCipher(secretKey)
    if err != nil {
        return err
    }

    // Wrap the cipher block in Galois Counter Mode.
    aesGCM, err := cipher.NewGCM(block)
    if err != nil {
        return err
    }

    // Create a unique nonce containing 12 random bytes.
    nonce := make([]byte, aesGCM.NonceSize())
    _, err = io.ReadFull(rand.Reader, nonce)
    if err != nil {
        return err
    }

    // Prepare the plaintext input for encryption. Because we want to
    // authenticate the cookie name as well as the value, we make this plaintext
    // in the format "{cookie name}:{cookie value}". We use the : character as a
    // separator because it is an invalid character for cookie names and
    // therefore shouldn't appear in them.
    plaintext := fmt.Sprintf("%s:%s", cookie.Name, cookie.Value)

    // Encrypt the data using aesGCM.Seal(). By passing the nonce as the first
    // parameter, the encrypted data will be appended to the nonce — meaning
    // that the returned encryptedValue variable will be in the format
    // "{nonce}{encrypted plaintext data}".
    encryptedValue := aesGCM.Seal(nonce, nonce, []byte(plaintext), nil)

    // Set the cookie value to the encryptedValue.
    cookie.Value = string(encryptedValue)

    // Write the cookie as normal.
    return Write(w, cookie)
}

func ReadEncrypted(r *http.Request, name string, secretKey []byte) (string, error) {
    // Read the encrypted value from the cookie as normal.
    encryptedValue, err := Read(r, name)
    if err != nil {
        return "", err
    }

    // Create a new AES cipher block from the secret key.
    block, err := aes.NewCipher(secretKey)
    if err != nil {
        return "", err
    }

    // Wrap the cipher block in Galois Counter Mode.
    aesGCM, err := cipher.NewGCM(block)
    if err != nil {
        return "", err
    }

    // Get the nonce size.
    nonceSize := aesGCM.NonceSize()

    // To avoid a potential 'index out of range' panic in the next step, we
    // check that the length of the encrypted value is at least the nonce
    // size.
    if len(encryptedValue) < nonceSize {
        return "", ErrInvalidValue
    }

    // Split apart the nonce from the actual encrypted data.
    nonce := encryptedValue[:nonceSize]
    ciphertext := encryptedValue[nonceSize:]

    // Use aesGCM.Open() to decrypt and authenticate the data. If this fails,
    // return a ErrInvalidValue error.
    plaintext, err := aesGCM.Open(nil, []byte(nonce), []byte(ciphertext), nil)
    if err != nil {
        return "", ErrInvalidValue
    }

    // The plaintext value is in the format "{cookie name}:{cookie value}". We
    // use strings.Cut() to split it on the first ":" character.
    expectedName, value, ok := strings.Cut(string(plaintext), ":")
    if !ok {
        return "", ErrInvalidValue
    }

    // Check that the cookie name is the expected one and hasn't been changed.
    if expectedName != name {
        return "", ErrInvalidValue
    }

    // Return the plaintext cookie value.
    return value, nil
}

Then we can switch our main.go file to use these new helpers like so:

File: main.go
package main

...

func setCookieHandler(w http.ResponseWriter, r *http.Request) {
    cookie := http.Cookie{
        Name:     "exampleCookie",
        Value:    "Hello Zoë!",
        Path:     "/",
        MaxAge:   3600,
        HttpOnly: true,
        Secure:   true,
        SameSite: http.SameSiteLaxMode,
    }

    err := cookies.WriteEncrypted(w, cookie, secretKey)
    if err != nil {
        log.Println(err)
        http.Error(w, "server error", http.StatusInternalServerError)
        return
    }

    w.Write([]byte("cookie set!"))
}

func getCookieHandler(w http.ResponseWriter, r *http.Request) {
    value, err := cookies.ReadEncrypted(r, "exampleCookie", secretKey)
    if err != nil {
        switch {
        case errors.Is(err, http.ErrNoCookie):
            http.Error(w, "cookie not found", http.StatusBadRequest)
        case errors.Is(err, cookies.ErrInvalidValue):
            http.Error(w, "invalid cookie", http.StatusBadRequest)
        default:
            log.Println(err)
            http.Error(w, "server error", http.StatusInternalServerError)
        }
        return
    }

    w.Write([]byte(value))
}

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.

$ curl -i http://localhost:3000/set
HTTP/1.1 200 OK
Set-Cookie: exampleCookie=hBGecbVJ2cI0yAwrbMYd5sv7qslxBJoGnk7LBLHVR9rKrqh1cTVs2IuWHZUOkl2fdYeIYmY=; Path=/; Max-Age=3600; HttpOnly; Secure; SameSite=Lax
Date: Wed, 28 Sep 2022 10:37:23 GMT
Content-Length: 11
Content-Type: text/plain; charset=utf-8

cookie set!

In my case the encrypted cookie value is:

hBGecbVJ2cI0yAwrbMYd5sv7qslxBJoGnk7LBLHVR9rKrqh1cTVs2IuWHZUOkl2fdYeIYmY=

If we base64-decode this value, we should now just see gibberish and our original "Hello Zoë!" value should no longer be visible.

$ echo "hBGecbVJ2cI0yAwrbMYd5sv7qslxBJoGnk7LBLHVR9rKrqh1cTVs2IuWHZUOkl2fdYeIYmY=" | base64url --decode
��q�I��4�+l������q��N���G�ʮ�uq5l؋���]�u��bf

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?

type User struct {
    Name string
    Age  int
}

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:

package main

import (
    "bytes"
    "encoding/gob"
    "encoding/hex"
    "errors"
    "fmt"
    "log"
    "net/http"
    "strings"

    "example.com/example-project/internal/cookies"
)

var secret []byte

// Declare the User type.
type User struct {
    Name string
    Age  int
}

func main() {
    // Importantly, we need to tell the encoding/gob package about the Go type
    // that we want to encode. We do this my passing *an instance* of the type
    // to gob.Register(). In this case we pass a pointer to an initialized (but
    // empty) instance of the User struct.
    gob.Register(&User{})

    var err error

    secret, err = hex.DecodeString("13d6b4dff8f84a10851021ec8608f814570d562c92fe6b5ec4c9f595bcb3234b")
    if err != nil {
        log.Fatal(err)
    }

    mux := http.NewServeMux()
    mux.HandleFunc("/set", setCookieHandler)
    mux.HandleFunc("/get", getCookieHandler)

    log.Print("Listening...")
    err = http.ListenAndServe(":3000", mux)
    if err != nil {
        log.Fatal(err)
    }
}

func setCookieHandler(w http.ResponseWriter, r *http.Request) {
    // Initialize a User struct containing the data that we want to store in the
    // cookie.
    user := User{Name: "Alice", Age: 21}

    // Initialize a buffer to hold the gob-encoded data.
    var buf bytes.Buffer

    // Gob-encode the user data, storing the encoded output in the buffer.
    err := gob.NewEncoder(&buf).Encode(&user)
    if err != nil {
        log.Println(err)
        http.Error(w, "server error", http.StatusInternalServerError)
        return
    }

    // Call buf.String() to get the gob-encoded value as a string and set it as
    // the cookie value.
    cookie := http.Cookie{
        Name:     "exampleCookie",
        Value:    buf.String(),
        Path:     "/",
        MaxAge:   3600,
        HttpOnly: true,
        Secure:   true,
        SameSite: http.SameSiteLaxMode,
    }

    // Write an encrypted cookie containing the gob-encoded data as normal.
    err = cookies.WriteEncrypted(w, cookie, secret)
    if err != nil {
        log.Println(err)
        http.Error(w, "server error", http.StatusInternalServerError)
        return
    }

    w.Write([]byte("cookie set!"))
}

func getCookieHandler(w http.ResponseWriter, r *http.Request) {
    // Read the gob-encoded value from the encrypted cookie, handling any errors
    // as necessary.
    gobEncodedValue, err := cookies.ReadEncrypted(r, "exampleCookie", secret)
    if err != nil {
        switch {
        case errors.Is(err, http.ErrNoCookie):
            http.Error(w, "cookie not found", http.StatusBadRequest)
        case errors.Is(err, cookies.ErrInvalidValue):
            http.Error(w, "invalid cookie", http.StatusBadRequest)
        default:
            log.Println(err)
            http.Error(w, "server error", http.StatusInternalServerError)
        }
        return
    }

    // Create a new instance of a User type.
    var user User

    // Create an strings.Reader containing the gob-encoded value.
    reader := strings.NewReader(gobEncodedValue)

    // Decode it into the User type. Notice that we need to pass a *pointer* to
    // the Decode() target here?
    if err := gob.NewDecoder(reader).Decode(&user); err != nil {
        log.Println(err)
        http.Error(w, "server error", http.StatusInternalServerError)
        return
    }

    // Print the user information in the response.
    fmt.Fprintf(w, "Name: %q\n", user.Name)
    fmt.Fprintf(w, "Age: %d\n", user.Age)
}

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: