moroz.dev

<< Back to index

Secure cookie library in Go from scratch

Abstract

In this post, I show you how to write a “secure cookie” library that will encrypt and authenticate payloads using the XChaCha20-Poly1305 AEAD, using the Go programming language.

The final code from the code snippets is available at github.com/moroz/securecookie. The library has not been reviewed by a cryptography expert and should therefore not be deemed production-ready.

The code is licensed under the BSD-3-Clause License. You may use it for any purpose at your own responsibility (I definitely will use it in some side projects).

If you enjoy exploring technological rabbit holes, you may like my Kernighan and Ritchie Challenge series on YouTube. Liking and subscribing goes a long way, thanks!

Introduction: What are cookies and why should I care?

Cookies are short1 strings of letters, digits, and symbols2 that a Web server may store in your browser. They are used to identify a user of a given Website between requests. Whenever you add a product to a virtual shopping cart or sign in to an online mailbox, that Website is going to give you a cookie that the server can later use to identify you.

The name cookie comes from the term magic cookie3, which predates the World Wide Web and refers to a value passed between programs unchanged. This is similar to a fortune cookie 🥠, which also contains a hidden message that can be passed around.

Cookies are an essential part of any Web application, and if you live in Europe, chances are you are going to be reminded about this fact every single day. There is a browser extension to fix that4.

In this article, I’m going to teach you how to implement a cookie-based session store using the Go programming language and an authenticated encryption scheme called XChaCha20-Poly1305.

But first, before we talk about the hows, we need to talk about the whys. Like, have you ever wondered what exactly is stored in that cookie?

Naïve Approach: Just Store the Value

Let us go back to the example of a virtual shopping cart. On the server, there is likely a database table called carts. When you put an item in the cart, the server will create a new row in that table (a “new cart”). Let’s say that your cart has the ID of 42.

The simplest approach is to just store the cart ID in the cookie:

HTTP/1.1 200 OK
Set-Cookie: cart_id=42
...

Then, when you visit your cart again, you send the cookie back to the server:

GET /cart HTTP/1.1
Cookie: cart_id=42
...

In a perfect world, this would work just fine! However, in a non-perfect world, this approach is going to be terribly insecure.

For starters, the cart ID is generated in a sequence (1, 2, 3, …), making it trivial to guess the next and previous identifier. Even worse, this ID is all that is needed to forge a cookie and fetch someone else’s cart.

With such poor security, a malicious actor (or just a curious bystander) could hack your Website and fetch all data in all carts, using just a for loop:

for (int i = 1; i <= 42; i++) {
    hack_the_website(i);
}

Not great! What can we do to remedy this? The short answer is: We need to make the cookie much harder to guess.

Approach One: A Random String

One approach to this problem is to use a long, random string. Any data that actually matters is then stored on the server, in a so-called session. This approach has been popularized by the popular PHP programming language, which introduced this feature back in 2000, with the release of version 4.0.0. In PHP, by default, the session data is stored in encrypted text files, but there also exist adapters for relational databases, such as PostgreSQL or SQLite, or data stores, such as Redis or Memcached. Since all the important data is already stored on the server, revoking a session is very easy—deleting the session data on the server is enough to invalidate the cookie.

Approach Two: Sign Your Cookies

Another way is to store all the necessary data inside the cookie. In order to ensure that the cookie was really generated by our server, the data needs to be either authenticated using a shared secret or cryptographically signed using a private key.

Authentication is performed using a Message Authentication Code (MAC) algorithm, which is based on a hash function. A MAC uses the same key for signing a value and for verification, meaning that the key must be kept secret. The most commonly used MAC is HMAC-SHA256, which is based on the SHA-2 hashing algorithm.

By contrast, a digital signature uses two keys: a private key, which must be kept secret, and a public key that we can reveal to anyone. The private key is used to sign messages, while the public key can only be used to verify an existing signature. A popular digital signature scheme is EdDSA, commonly used in the variant known as Ed25519. If you have an account on Github, chances are that you are already using EdDSA signatures to upload and download code over SSH.

Approach Three: Encrypt Your Cookies, Then Sign Them

Both MACs and digital signature schemes guarantee that a cookie cannot be forged or tampered with, but the actual value stored in the cookie is still stored in plaintext. If you also wish to hide the value from nosy users, you need to encrypt it first, e. g. using a cipher like AES.

AES is a block cipher, meaning that in its purest form, it can only encrypt a single block at a time. In the case of AES, the block size is 16 bytes or 128 bits. If the message you need to encrypt is longer than a single block, you need to figure up an algorithm to let you securely apply the cipher over multiple blocks of data. Several such algorithms were designed, and collectively, they are called modes of operation. Two modes of operation have made their way to modern TLS implementations: Cipher Block Chaining (CBC) and Galois Counter Mode (GCM).

AES-CBC (Cipher Block Chaining)

The CBC mode of operation was patented back in 19765. In this mode of operation, each block of data is combined with another value using XOR before passing through the block cipher. For the first block of plaintext, a unique value called an initialization vector (IV) is used, and for each subsequent block of data, the previous block of ciphertext is used. The length of the message must be a multiple of the block size, meaning that shorter messages need to be padded with additional data.

The CBC mode only deals with the encryption of data, providing confidentiality, which means that the encrypted message cannot be read by anyone without the secret key. It is not concerned with authentication, therefore it needs to be used in combination with a MAC to ensure the integrity and authenticity of a message.

The CBC mode, although undeniably clever, is currently considered insecure and its usage in TLS 1.2 has been deprecated. The most recent revision of TLS, version 1.3, does not use the CBC mode of operation at all. This solves a whole lot of vulnerabilities in the protocol, making it much harder to eavesdrop on TLS-encrypted traffic. TLS 1.3 is therefore banned in China6.

AES-GCM (Galois Counter Mode)

The Galois Counter Mode (GCM) mode of operation is a much later invention than the CBC mode of operation, with the initial paper by McGrew and Viega7 published in 20048. AES in Galois Counter Mode does not encrypt the data at all. Instead, for each encrypted message, a unique value is used. The GCM spec calls it an initialization vector, just like CBC, but they are also colloquially referred to as nonces (short for number once). The IV in GCM does not need to be random. In fact, you can even use sequential numbers, as long as you can guarantee that a given value will never be reused (easier said than done). For AES-GCM, the nonce is usually 96 bits (12 bytes).

GCM is an authenticated encryption scheme, meaning that it guarantees not only the privacy of a message, but also its authenticity. An authenticated encryption scheme therefore seems like a perfect choice for use in cookies. AES-GCM also supports adding some extra information about the message (e. g. the HTTP origin of the website or the name of the cookie it is stored in). It is therefore called an authenticated encryption with associated data (AEAD) scheme.

GCM differs from CBC in that in GCM mode, you do not encrypt the message, you only encrypt the IV and a counter. Let me explain how this works in practice.

Let’s say your plaintext is 2137 bytes long. With a block size of 16 bytes, we need to encrypt 2137 / 16 = 133.5625 ≈ 134 blocks of data. Then, pick a nonce, let’s say, the number 42. Encoded in 96 bits, big-endian, it looks like this:

00 00 00 00 00 00 00 00 00 00 00 2A

The remaining 32 bits of each block contain the block counter. For the first block of plaintext, we use the number 1, and for the last one, the number 134:

# first block (index = 1)
00 00 00 00 00 00 00 00 00 00 00 2A 00 00 00 01

# last block (index = 134)
00 00 00 00 00 00 00 00 00 00 00 2A 00 00 00 89

In order to encrypt 134 blocks of plaintext, encrypt each of these blocks using the encryption key. Since the output of AES encryption for a single block is always 16 bytes long, we can calculate that encrypting 134 blocks will yield 134 × 16 = 2144 bytes of random-looking data, which we could probably call a keystream9. Then, since the resulting binary is longer than the plaintext, we can discard the last 7 bytes of the mask. Then, combine the plaintext with the mask using XOR.

Since XOR is a reversible operation, when decrypting a message encrypted with AES-GCM, we perform the exact same operation to generate the mask, and this mask, combined with the ciphertext using XOR, should return the plaintext.

In contrast with the CBC mode of operation, in which the ciphertext of the previous block is combined with each following block, in GCM mode, there is no dependency between blocks. This implies that you can compute these in parallel and in any order you want, allowing for all sorts of optimizations.

Now, you may ask: where did the block with the index 0 go? This block is not used in the encryption process, it is only used for authentication. If you want to know exactly how this algorithm works, you can read the original proposal by David A. McGrew and John Viega7.

The Random Nonce Problem

As mentioned above, the nonce used in AES-GCM mode is 96 bits long and must not be reused. In theory, that gives us 296 possible nonces, which should be more than enough for unlimited use by all of humanity and Santi people10, until the Universe dies of hypothermia.

Unfortunately, due to a phenomenon called the birthday problem, if you generate nonces using a random (or pseudo-random) number generator, the risk of a nonce collision increases greatly.

For this reason, the National Institute of Standards and Technology11 recommends that:

The total number of invocations of the authenticated encryption function shall not exceed 232, including all IV lengths and all instances of the authenticated encryption function with the given key.

Now, 232 is definitely not a small number, but now the risk of a nonce collision is much higher than the original, naïve estimate of 2−96. We could choose to simply ignore this issue. After all, who is going to sit around and submit the login form 232 times to trigger a nonce collision in our signed cookie implementation?

We could also implement a key rotation scheme, for instance, rotating the key every n days, to ensure that the 96-bit nonces really do not collide. Now, securely rotating encryption keys is a great challenge in and of itself. Mind you, it may be hard to calculate how often, exactly, you would need to rotate the key. If it’s just for a side project, the answer is most likely going to be: never. However, for some services at scale, the threshold of 232 invocations could well be reached within a single day. There ought to be a better way!

Introducing XChaCha20-Poly1305

There are ways to deal with the nonce reuse problem in AES-GCM, the most notable of them being AES-GCM-SIV. AES-GCM-SIV achieves this property by deriving an initialization vector for the AEAD based on the provided nonce, the additional authenticated data, and the entirety of the plaintext. This, however, requires going over the whole plaintext twice: Once to derive an IV, and once to encrypt the plaintext.

The XChaCha-Poly1305 AEAD was built as an alternative solution. Unlike AES-GCM, which is based on the block cipher AES, it is based on a stream cipher called ChaCha20. designed with the specific goal of being resistant to nonce reuse in mind. XChaCha20 uses 192-bit nonces, which can safely be generated randomly.

Now, let us delve into the implementation part12.

Implementing our securecookie library

Create a directory for the project. I called my library securecookie, inspired by gorilla/securecookie, but you may pick a different name13. However, you may want to make sure the package name does not collide with popular packages from the standard library, such as http or context, as that would be very inconvenient for the end user.

mkdir securecookie

Initialize a new Go project. You may want to replace moroz with your username.

cd securecookie
go mod init github.com/moroz/securecookie

Install the golang.org/x/crypto/chacha20poly1305 library. It contains the cryptographic primitives that we will use to encrypt, decrypt, and authenticate messages.

go get -u golang.org/x/crypto/chacha20poly1305

Create a new file called securecookie.go. In this file, let’s start by defining a few constants, which we will use when implementing the hard part (the part with actual cryptography):

package securecookie

import "golang.org/x/crypto/chacha20poly1305"

const (
	KeySize   = chacha20poly1305.KeySize
	NonceSize = chacha20poly1305.NonceSizeX
	Overhead  = chacha20poly1305.Overhead
)

The names of the KeySize and NonceSize constants are rather self-explanatory. Note that the nonce size is equal to a constant called NonceSizeX, with an X at the end. The X at the end indicates that this is the size of a nonce in XChaCha20-Poly1305, which is 192 bits or 24 bytes, unlike regular ChaCha20-Poly1305, whose nonce is 96 bits long. The constant called Overhead corresponds to the size of the authentication tag, or the checksum that will be appended at the end of the authenticated ciphertext.

Define the Store interface

Now, let us step back for a moment and think about the actual functionality that we want to implement. What we need is a library that can encrypt and authenticate an arbitrary binary string and serialize it to a format that can be safely stored in a cookie2. Then, we would also need a way to decrypt and verify the message loaded from a cookie.

These requirements can be expressed as the following interface:

type Store interface {
	// Encrypt encrypts and authenticates the given plaintext and produces a binary
	// message that contains all the information necessary to verify it.
	Encrypt(plaintext []byte) (msg []byte, err error)

	// Decrypt decrypts and verifies a binary message and returns the original
	// binary plaintext.
	Decrypt(msg []byte) (plaintext []byte, err error)

	// EncryptCookie encrypts the given plaintext and produces a string that can be
	// safely stored in an HTTP cookie.
	EncryptCookie(plaintext []byte) (cookie string, err error)

	// DecryptCookie decodes a cookie generated by `EncryptCookie` and returns
	// the original binary plaintext.
	DecryptCookie(cookie string) (plaintext []byte, err error)
}

Implement a store struct

Now, let us implement a concrete type to implement this interface. We can start by defining a simple struct to store the secret key.

type store struct {
	key []byte
}

Since this type is not exported, we can only use it if we also define a constructor function. We can use this opportunity to validate the length of the provided secret key:

var ErrKeySize = errors.New("Invalid key size")

func NewStore(key []byte) (Store, error) {
	if len(key) != KeySize {
		return nil, fmt.Errorf("%w %d (want %d)", ErrKeySize, len(key), KeySize)
	}

	return &store{key: key}, nil
}

An interesting observation is that even though we defined the return type as (Store, error), returning nil, error still satisfies the Go compiler. This is because an interface value can be backed by a nil concrete value14 (but you would still get a panic if you tried to call a method on a nil value).

Encrypt: encrypts and authenticates binary data

At this point, this code is not terribly helpful. We can’t event test it, because the store type does not implement any of the four methods defined in our interface Store. Let us start with the encryption part.

// import "crypto/rand"

// Encrypt encrypts and authenticates the binary plaintext with the secret key.
func (s *store) Encrypt(plaintext []byte) ([]byte, error) {
	// Allocate buffer with the initial size just big enough to generate a random nonce,
	// but with capacity for the whole message (nonce + ciphertext + authentication tag)
	nonce := make([]byte, NonceSize, NonceSize+len(plaintext)+Overhead)

	// Generate a random nonce. `rand.Read` will only generate as much data as can fit
    // within the initial size of the slice
	if _, err := rand.Read(nonce); err != nil {
        return nil, err
    }

    // Initialize an XChaCha20-Poly1305 AEAD with the secret key
	aead, err := chacha20poly1305.NewX(s.key)
	if err != nil {
		return nil, err
	}

	// Encrypt and authenticate the message
	msg := aead.Seal(nonce, nonce, plaintext, nil)

    // The return value is nonce + ciphertext + authentication tag in one byte slice
	return msg, nil
}

Let us analyze the code bit by bit.

nonce := make([]byte, NonceSize, NonceSize+len(plaintext)+Overhead)

First, we allocate a buffer with the initial length of NonceSize bytes, but with capacity for the whole authenticated message. Later on, this byte slice will contain not just the nonce… but the ciphertext, and the authentication tag, too.

if _, err := rand.Read(nonce); err != nil {
    return nil, err
}

Then, we generate a random nonce (initialization vector) using rand.Read. Since we set the initial length of the nonce slice to NonceSize, we can be sure that the nonce generated using will be exactly NonceSize bytes long. This is because under the hood, rand.Read uses io.ReadFull to copy random data from rand.Reader. According to its documentation, the io.ReadFull function “…reads exactly len(buf) bytes…”, which will be equal to the second argument passed to make.

aead, err := chacha20poly1305.NewX(s.key)
if err != nil {
    return nil, fmt.Errorf("Encrypt: %w", err)
}

Then, we initialize a new XChaCha20-Poly1305 AEAD with the secret key. The chacha20poly1305.NewX function’s first return value is an interface type called cipher.AEAD. This interface has two methods that are of interest to us:

type AEAD interface {
        // Seal encrypts and authenticates plaintext, authenticates the
        // additional data and appends the result to dst, returning the updated
        // slice.
        // ...
        Seal(dst, nonce, plaintext, additionalData []byte) []byte

        // Open decrypts and authenticates ciphertext, authenticates the
        // additional data and, if successful, appends the resulting plaintext
        // to dst, returning the updated slice. The nonce must be NonceSize()
        // bytes long and both it and the additional data must match the
        // value passed to Seal.
        // ...
        Open(dst, nonce, ciphertext, additionalData []byte) ([]byte, error)
}

Based on this interface, I understand the AEAD to use the metaphor of a sealed letter: While a letter is sealed, no-one, not even the person who sealed it, can read it or modify its content. At the same time, a proper seal guarantees that the message really comes from the purported sender. Analogously, a message encrypted and authenticated with an AEAD is sealed from prying eyes and it cannot be modified.

This is how we use Seal in our Encrypt method:

msg := aead.Seal(nonce, nonce, plaintext, nil)

This call will encrypt and authenticate the plaintext using nonce, writing the result to the byte slice currently used by the nonce. We pass nil to the last argument, additionalData, indicating that we do not wish to use any additional authenticated data.

return msg, nil

Finally, we return the message and nil, indicating that there was no error.

Decrypt decrypts and verifies the provided message

The next step is implementing the Decrypt method to decode and verify AEAD-encrypted messages. Unsurprisingly, in this method, we are going to use the Open method of the cipher.AEAD interface:

var ErrMsgTooShort = errors.New("encrypted message too short")

// Decrypt decrypts and verifies the provided message in the format: nonce+ciphertext+authentication tag.
// Returns verified plaintext and error, if any.
func (s *store) Decrypt(message []byte) ([]byte, error) {
	if len(message) < NonceSize+Overhead {
		return nil, fmt.Errorf("%w (got %v, want %v or more)",
			ErrMsgTooShort, len(message), NonceSize+Overhead)
	}

	nonce, ciphertext := message[:NonceSize], message[NonceSize:]
	aead, err := chacha20poly1305.NewX(s.key)
	if err != nil {
		return nil, fmt.Errorf("Decrypt: %w", err)
	}

	return aead.Open(nil, nonce, ciphertext, nil)
}

Let us analyze this method in detail.

First, we validate that the message is long enough to be a valid AEAD-encrypted message:

if len(message) < NonceSize+Overhead {
    return nil, fmt.Errorf("%w (got %v, want %v or more)",
        ErrMsgTooShort, len(message), NonceSize+Overhead)
}

If the message is to short, we return a wrapped error containing detailed information about the discrepancy (as in: got 12, want 40 or more).

By design, any message encrypted with Encrypt has the combined length of its plaintext, the nonce (initialization vector), and the authentication tag. As per the ChaCha20-Poly1305 spec15, on which XChaCha20-Poly1305 is based, both the plaintext and the additional associated data can be of arbitrary length, including zero length. This implies that the minimal length of a valid AEAD-authenticated message is nonce size + length of the authentication tag. While a malformed message would have been rejected by the AEAD implementation, we need to validate the length of the encrypted message to ensure the correct execution of the following line:

nonce, ciphertext := message[:NonceSize], message[NonceSize:]

Split the message into the nonce and the remainder (ciphertext with authentication tag). This operation would cause a panic (runtime error: slice bounds out of range) if we tried to perform it on an input value shorter than NonceSize.

aead, err := chacha20poly1305.NewX(s.key)
if err != nil {
    return nil, fmt.Errorf("Decrypt: %w", err)
}

Initialize a cipher.AEAD the same way as in Encrypt.

return aead.Open(nil, nonce, ciphertext, nil)

Finally, “open” the “sealed letter.” On success, return the authenticated plaintext as a byte slice and an empty error (nil). On failure, return an empty slice (nil) and the error returned by Open.

Until now, we have been dealing exclusively with byte slices rather than actual cookies. The EncryptCookie method wraps the Encrypt method to produce an encrypted that can securely be stored in a cookie. Luckily, this is very easy to do:

// import "encoding/base64"

// EncryptCookie encrypts plaintext and produces a cookie
func (s *store) EncryptCookie(plaintext []byte) (string, error) {
	msg, err := s.Encrypt(plaintext)
	if err != nil {
		return "", err
	}

	return base64.RawURLEncoding.EncodeToString(msg), nil
}

Let us quickly go over each step. First, we call Encrypt:

msg, err := s.Encrypt(plaintext)
if err != nil {
    return "", err
}

If the call to Encrypt fails for some reason, return an empty string and the original error.

return base64.RawURLEncoding.EncodeToString(msg), nil

Finally, encode the binary message to base64url16, without padding. Regular base64 encoding would work as well: The only difference between base64 and base64url is that the latter uses - and _ in place of + and /. However, all four are valid in cookies2. The choice of one or the other is thus a matter of personal preference, and I prefer base64url.

The only remaining method required by our Store interface is DecryptCookie, which performs the inverse of EncryptCookie. Its implementation is equally straightforward:

// DecryptCookie decrypts and verifies cookie and returns plaintext
func (s *store) DecryptCookie(cookie string) ([]byte, error) {
	msg, err := base64.RawURLEncoding.DecodeString(cookie)
	if err != nil {
		return nil, fmt.Errorf("DecryptCookie: %w", err)
	}

	return s.Decrypt(msg)
}

First, decode the cookie using the same encoding scheme as used in EncryptCookie:

msg, err := base64.RawURLEncoding.DecodeString(cookie)
if err != nil {
    return nil, fmt.Errorf("DecryptCookie: %w", err)
}

In the case of an error, I opted to wrap the error value with the name of the calling function. This error may be triggered by any malformed input, and for that reason, I believe it is much more likely to occur than an unspecified encryption error. Adding this prefix may help pinpoint the source of the issue during debugging.

Finally, call Decrypt to decrypt and verify the message:

return s.Decrypt(msg)

On success, this call will return the verified plaintext.

Testing the implementation

Now, before we can roll this out to our Web-scale application, we need to test if it even works. In the test suite, I will be using convenience functions from the library github.com/stretchr/testify/assert. Install it like so:

go get -u github.com/stretchr/testify/assert

An in-depth explanation of Go tests is beyond the scope of this article.

Let us start by testing the constructor. Create a file called securecookie_test.go in the root directory of the project:

package securecookie_test

import (
	"bytes"
	"crypto/rand"
	"encoding/base64"
	"testing"

	"github.com/moroz/securecookie"
	"github.com/stretchr/testify/assert"
)

func TestNewStore(t *testing.T) {
	t.Parallel()

	examples := []struct {
		key   []byte
		valid bool
	}{
		{
			key:   []byte{1, 2, 3},
			valid: false,
		},
		{
			key:   bytes.Repeat([]byte{1}, securecookie.KeySize+1),
			valid: false,
		},
		{
			key:   bytes.Repeat([]byte{1}, securecookie.KeySize),
			valid: true,
		},
	}

	for _, example := range examples {
		_, err := securecookie.NewStore(example.key)
		if example.valid {
			assert.NoError(t, err)
		} else {
			assert.ErrorIs(t, err, securecookie.ErrKeySize)
		}
	}
}

In this example, we check that the constructor only accepts keys of length equal to KeySize. We iterate over a list of examples, checking if the constructor returns an error when called with keys of different lengths. We check that it returns ErrKeySize if the key passed to NewStore is too short or too long, and that there is no error when the key is exactly KeySize bytes long.

Testing Encrypt and Decrypt

Now that we know that the constructor works, let us test the Encrypt and Decrypt methods.

In order to do that, we need to first initialize the store with a proper secret key. This helper method generates a secret key of the desired length:

func generateKey() []byte {
	var key = make([]byte, securecookie.KeySize)
	if _, err := rand.Read(key); err != nil {
		panic(err)
	}
	return key
}

Now, let us go ahead and test the workflow:

func TestEncryptDecrypt(t *testing.T) {
	store, err := securecookie.NewStore(generateKey())
	assert.NoError(t, err)

	plaintext := []byte("OrpheanBeholderScryDoubt")

	msg, err := store.Encrypt(plaintext)
	assert.NoError(t, err)
	assert.Len(t, msg, len(plaintext)+securecookie.NonceSize+securecookie.Overhead)

	decrypted, err := store.Decrypt(msg)
	assert.NoError(t, err)
	assert.Equal(t, plaintext, decrypted)
}

We test that both Encrypt and Decrypt methods do not return any errors, and that the encryption process can be correctly reversed.

Testing EncryptCookie and DecryptCookie

The next step would be testing that the value returned by EncryptCookie is a valid cookie value, as per RFC 62652. The specification defines a finite set of ASCII characters that may appear in the cookie value. Even though theoretically all Base64 characters are included in that set, it probably would not hurt to test it anyway.

The following functions to check whether each rune in the generated cookie is in the valid character set:

// validateCookieOctet checks whether c is a valid cookie-octet as defined
// in RFC 6265 (https://httpwg.org/specs/rfc6265.html#sane-set-cookie):
//
// cookie-octet      = %x21 / %x23-2B / %x2D-3A / %x3C-5B / %x5D-7E
//
//	; US-ASCII characters excluding CTLs,
//	; whitespace DQUOTE, comma, semicolon,
//	; and backslash
func validateCookieOctet(c rune) bool {
	return c == 0x21 || c >= 0x23 && c <= 0x2B || c >= 0x2D && c <= 0x3A ||
		c >= 0x3C && c <= 0x5B || c >= 0x5D && c <= 0x7E
}

// validateCookieValue checks whether each rune in the cookie is a valid cookie-octet,
// as per RFC 6265 (https://httpwg.org/specs/rfc6265.html#sane-set-cookie)
// cookie-value      = *cookie-octet / ( DQUOTE *cookie-octet DQUOTE )
func validateCookieValue(cookie string) bool {
	for _, c := range cookie {
		if !validateCookieOctet(c) {
			return false
		}
	}
	return true
}

Armed with these helpers, we can go ahead and test EncryptCookie and DecryptCookie:

func TestEncDecCookie(t *testing.T) {
	store, err := securecookie.NewStore(generateKey())
	assert.NoError(t, err)

	plaintext := []byte("OrpheanBeholderScryDoubt")

	cookie, err := store.EncryptCookie(plaintext)
	assert.True(t, validateCookieValue(cookie))

	decrypted, err := store.DecryptCookie(cookie)
	assert.NoError(t, err)
	assert.Equal(t, plaintext, decrypted)

	// Try to tamper the cookie by NOTing a byte in the signature
	binary, _ := base64.RawURLEncoding.DecodeString(cookie)
	i := len(binary) - 5
	binary[i] = ^binary[i]

	tampered := base64.RawURLEncoding.EncodeToString(binary)

	got, err := store.DecryptCookie(tampered)
	assert.ErrorContains(t, err, "message authentication failed")
	assert.Nil(t, got)
}

I have also included a simple test to check whether an invalid authentication tag would be caught by the AEAD. To this end, I decode the cookie (from Base64), modify a single byte in its authentication tag with a simple bitwise operation (flipping all the bits in the 6th last byte), and reencode it as Base64. As expected, the tampered cookie is rejected by the library.

How this article came to be

I originally wrote the securecookie library as a proof of concept. It was partly inspired by the gorilla/securecookie library, used under the hood by gorilla/sessions.

My initial implementation can be found at github.com/moroz/securecookie-poc.

The original gorilla library does a few things in a way that I, in my hubris, have deemed naïve: The authentication part is implemented in a bespoke way, using a combination of string concatenation and HMAC-SHA-256. The payload can optionally be encrypted using a cipher, but the constructor only supports block ciphers that are then applied in counter mode (which excludes the whole category of stream ciphers, I guess?)

Gorilla’s library supports timestamps that can be validated against a maximum age. However, in a production setting, a cookie should also be verified against some value in the database (like a session token), so this validation is redundant. Finally, the library uses an additional value called “cookie name,” which is essentially additional data.

During my search for A Better Solution, I encountered and became enamored with the concept of AEADs. The construct looked like a perfect fit: they can guarantee both secrecy and authenticity of a message, and you can put the name of the cookie (or any other metadata) in the additional data.

Reddit feedback

I posted the original proof of concept to Reddit17.

Most commenters asked for a mechanism to rotate the keys. You can implement this easily by storing a slice of secret keys in the store. Whenever you encrypt a new cookie, use the first one (or the last one). When decrypting an existing cookie, try with all keys.

I do not think this mechanism is particularly useful. With XChaCha20-Poly1305, you should never need to rotate the key because of nonce reuse. If your secret key is compromised, you should treat all messages encrypted with this key as untrusted, anyway.


  1. As a rule of thumb, the maximum size of all cookies stored for a domain should not exceed around 4 kB (4096 bytes). ↩︎

  2. According to RFC 6265, all the characters permitted within a cookie are: AZ, az, 09, and the following: !#$%&’()*+-./:<=>?@[]^_`{|}~. Note that spaces, double quotes ("), and semicolons (;) are not permitted. ↩︎ ↩︎ ↩︎ ↩︎

  3. Stuart, A. (2002). Where cookie comes from. Retrieved January 14, 2025, from http://dominopower.com/article/where-cookie-comes-from/↩︎

  4. If you are tired of obnoxious cookie banners, you can hide them using the browser extension I still don’t care about cookies (available for Chrome/Edge and Firefox). ↩︎

  5. Ehrsam, W. F., Meyer, C. H. W., Smith, J. L., & Tuchman, W. L. (1976). Message verification and transmission error detection by block chaining. US Patent 4074066. ↩︎

  6. Cimpanu, C. (2020). China is now blocking all encrypted HTTPS traffic that uses TLS 1.3 and ESNI. ZDNet. Retrieved January 16, 2025, from https://www.zdnet.com/article/china-is-now-blocking-all-encrypted-https-traffic-using-tls-1-3-and-esni/↩︎

  7. McGrew, D. A., & Viega, J. (n.d.). The Galois/Counter Mode of Operation (GCM). Retrieved January 17, 2025, from https://luca-giuzzi.unibs.it/corsi/Support/papers-cryptography/gcm-spec.pdf↩︎ ↩︎

  8. Or maybe 2005. I have not found a conclusive source. ↩︎

  9. In a similar context, the ChaCha20 spec calls it a keystream. ChaCha20 is a stream cipher, unlike AES, so I’m not sure if the wording can be used interchangeably. ↩︎

  10. https://en.wikipedia.org/wiki/The_Three-Body_Problem_(novel), https://baike.baidu.com/item/三体人/8709210 (Chinese). ↩︎

  11. Dworkin, M. (2007). Recommendation for block cipher modes of operation: Galois/Counter Mode (GCM) and GMAC (NIST SP 800-38D). National Institute of Standards and Technology. Retrieved January 19, 2025, from https://doi.org/10.6028/NIST.SP.800-38D↩︎

  12. Being, in my essence, a large language model, I enjoy delving into topics. ↩︎

  13. For instance, you may pick securebiscuit if you are from the UK. ↩︎

  14. Go Programming Language Specification. (n.d.). Interface types. Retrieved January 25, 2025, from https://go.dev/ref/spec#Interface_types ↩︎

  15. IETF. (2018). RFC 8439: ChaCha20 and Poly1305 for IETF protocols. Internet Engineering Task Force. Retrieved February 6, 2025, from https://datatracker.ietf.org/doc/html/rfc8439#section-2.8↩︎

  16. IETF. (2006). RFC 4648: The Base16, Base32, and Base64 Data Encodings. Internet Engineering Task Force. Retrieved February 6, 2025, from https://datatracker.ietf.org/doc/html/rfc4648#section-5↩︎

  17. Reddit user moroz_dev. (2025). Demistifying signed cookies: A proof-of-concept “secure cookie” library. Reddit. Retrieved February 7, 2025, from https://www.reddit.com/r/golang/comments/1hychmy/demistifying_signed_cookies_a_proofofconcept/ ↩︎