C is for (HTTP) Cookies

4 minute read

I’ve been working on a post about using JWTs JSON Web Token (JWT) when you can’t use HTTP cookies for sessions. As I dug into it, I came to realize that understanding the risks of using JWTs requires a bit of understanding about how HTTP cookies (and session) work. So, here’s a primer. You probably have at least a passing familiarity with both cookies and session, but it’s worth a deeper understanding so you can make good choices when not using them. This time we’ll look at the nuts and bolts of cookies, next time sessions.

As you probably know an HTTP cookie is a key value store. Under the hood, the key/value pair is sent as an HTTP header, Set-Cookie. When a browser sees this header it stores the cookie. Next time the browser makes a request to the server, it sends the cookie back in the Cookie header. When setting cookies, multiple cookies == multiple Set-Cookie headers. The browser, on the other hand, can send multiple semicolon separate cookies in the same Cookie header.

If you met them on the street, they’d look something like:

Set-Cookie: key=value
Set-Cookie: key2=another-value
Cookie: key=value; key2=another-value

All web frameworks, and even web friendly programming languages, provide some structure so you don’t have to generate and parse cookie headers yourself. In Rails, it’s a magical hash-like object called, wait for it, cookies! What can we do with it? A fine example is saving a visitor’s language preference.

We set it:

cookies[:language] = 'italiano'

and, as the visitor continues to browse the site, we can get it back:

language = cookies[:language]

By default, cookies are scoped to the host name, so a cookie from www.example.org would not be available to shop.example.org. (Cookies are never available cross domain, so foo.org is never going to see cookies from example.org.) However, the scope can be expand to the domain, i.e. .example.org, in which case cookies set by www are available to shop and vice versa. With this, we can set a visitor’s language preference across all of our sites:

In Rails you set cookie options by passing in a hash instead of just a value:

cookies[:language] = {value: 'italiano', domain: 'example.org'}

Cookies can also by scoped by path. By default cookies are available to any URL on the site, but it’s possible to limit them to a path, like /shop. Typically, this is used to avoid collisions when different apps use the same name for cookies. Again in Rails:

cookies[:key] = {value: value, path: '/shop'}

Cookies can and will expire. The default is when the “session ends” which typically means when you quit the browser. However, you can also set an explicit expiration. Say we need to display a disclaimer every 30 days:

cookies[:viewed_disclaimer] = {value: true, expires: 30.days.from_now }

Rails also has a shortcut for “permanent” cookie, which creates a cookie that expires in 20 years.

cookies.permanent[:viewed_disclaimer] = true # Once is enough.

Keep in mind nothing is truly “permanent”, least of all data. The visitor may delete the cookies at anytime. They may also switch browsers or visit from a different device, neither of which will have the cookie set.

Cookies have two very important security settings, Secure and HttpOnly. Both of these are off by default in Rails.

Secure says “Only send this cookie over HTTPS.” When this is off, the browser will send the cookie every time it visits the site, when it’s on, the browser will only send the cookie when it visits HTTPS URLs, thus protecting the cookie from being sniffed on the network.

cookies[:private] = {value: secret', secure: true }

Note that this setting if for the browser only! It’s up to you to make sure the server doesn’t send “secure” cookies over HTTP.

HttpOnly is confusing as it doesn’t have anything to do with HTTPS vs HTTP. What it actually means is not available to JavaScript (only available to the HTTP protocol stack). When this is off, JavaScript can access the cookie via document.cookie, when it’s on, the cookie is invisible to JavaScript. This prevents cookie data from being stolen via Cross-site scripting (XSS) attack.

cookies[:hidden] = {value: 'No cookie for you!', httponly: true }

Finally, rails provides two extensions to prevent tampering with or even viewing cookies values. Cookies are opaque to your average user, but with a bit of knowledge your can change them browser side. Consider a logged in user, you might store their user ID in a cookie and use it to display the correct dashboard. If the user changed that value, they would be able to view other users’ dashboards. Rails prevents this with signed cookies:

cookies.signed[:user_id] = current_user.id

Read them back with:

user_id = cookies.signed[:user_id] # cookies[:user_id] would return gibberish

An exception will be raised if the cookie has been tampered with.

If you want to both protect the cookie and hide it’s value, you can encrypt it instead:

cookies.encrypted[:user_id] = current_user.id
user_id = cookies.encrypted[:user_id] # again cookies[:user_id] is unreadable

Again, an exception will be raised if the cookie has been tampered with and thus can’t be decrypted.

If you get one thing out of this article it should be this, while cookies are handy for keeping a bit of state information, but once you need security, you have to pay attention do a bit of work. By default your cookies and the data they contain are a) public and b) not trustworthy.

It would be nice if there a tool to help make security little more foolproof. And of course, that’s our topic next time, Sessions!

Comments