Secure session
Signed session cookies
In the previous chapter, we stored the raw user_id
in the session cookie to identify a given user. This is very insecure because the user_id
is an incremental value, making it very easy to change the value of the cookie to impersonate another user.
To illustrate the security issue, let's completely reset the database:
bin/rails db:drop db:create db:migrate
We can then open a rails console
and create two users:
User.create! email: "[email protected]", password: "password"
User.create! email: "[email protected]", password: "password"
This will create two users in the database. The first one has an id
of 1 and the second one has an id
of 2.
Now let's open a new private browser window to ensure that we don't already have a session cookie. Note that we can also open a standard browser window and manually delete the cookies.
Let's open the sign-in form and sign in with the first user. We should be redirected to the dashboard and see "Welcome [email protected]".
Let's now imagine we are an attacker and want to impersonate the second user. We can open the browser developer tools and change the value of the user_id
cookie to 2.
To do this with Google Chrome, we can open the developer tools with Cmd + Option + I
and go to the Application
tab. We can then find the localhost
entry in the Cookies
section and change the value of the user_id
cookie to 2.
If we refresh the dashboard page, we should now see "Welcome [email protected]"! We were able to impersonate another user. This is a huge security issue!
To fix this, we need to ensure the value of the cookie can't be tampered with. In the "User Model" lesson, we talked about signing as a mechanism to prevent tampering. We can use the same mechanism to sign the session cookie.
Let's make a simple change to our Authentication
concern to use signed cookies:
# app/controllers/concerns/authentication.rb
module Authentication
# All the previous code
def restore_authentication
Current.user = User.find_by(id: cookies.signed[:user_id])
end
# All the previous code
def sign_in(user)
cookies.signed.permanent[:user_id] = user.id
end
end
Let's now perform the same experiment again. Let's close the previous private browser window to clear all the cookies and open a new one or simply clear the cookies manually from the current browser window.
Let's open the sign-in form and sign in again with the first user. We should once again be redirected to the dashboard and see "Welcome [email protected]".
If we now try to change the value of the user_id
cookie to 2, we should see that it's not as easy as it previously was. The value of the cookie is now signed and looks like the following:
eyJfcmFpbHMiOnsibWVzc2FnZSI6Ik1RPT0iLCJleHAiOiIyMDQ0LTA3LTI5VDEwOjQ3OjM3LjUxM1oiLCJwdXIiOiJjb29raWUudXNlcl9pZCJ9fQ%3D%3D--03ec036f73f17ae7e3f876b3f74541d8032f49d9
Urgh, says the attacker, that's going to be much harder to change to impersonate another user.
Let's have a closer look at this string and analyze it. As we can see, it's divided into two parts separated by two dashes --
. The first part is the actual value of the cookie. The second part is the signature that is unique to this value.
If you remember well from the User Model lesson where we talked about signing, the first part of the string is public and anyone can read it simply by decoding the Base64 text:
payload, signature = "eyJfcmFpbHMiOnsibWVzc2FnZSI6Ik1RPT0iLCJleHAiOiIyMDQ0LTA3LTI5VDEwOjQ3OjM3LjUxM1oiLCJwdXIiOiJjb29raWUudXNlcl9pZCJ9fQ%3D%3D--03ec036f73f17ae7e3f876b3f74541d8032f49d9".split("--")
value = JSON.parse(Base64.decode64(CGI.unescape(payload)))
# => {"_rails"=>{"message"=>"MQ==", "exp"=>"2044-07-29T10:47:37.513Z", "pur"=>"cookie.user_id"}}
We can further decode the message part of the payload, and we will get a user_id
of 1:
Base64.decode64(value["_rails"]["message"])
# => "1"
However, as the signature part is unique to this value, even if we replaced the 1
with 2
in the payload, the signature would become invalid and the cookie would be rejected!
With signed cookies, our authentication system is already much more secure than it was before.
HTTP-only cookies
One of the most common vulnerabilities in web applications is Cross-Site Scripting (XSS). This vulnerability allows an attacker to inject malicious scripts into web pages viewed by other users. These scripts can steal sensitive information such as session cookies and send them to the attacker. That way, the attacker can impersonate the user and perform actions on their behalf.
By default, Rails protects you against XSS attacks, but in my experience, it's quite common to have subtle XSS vulnerabilities in web applications. To protect cookies against this kind of attack, we can mark them as being HTTP-only by using the http_only: true
option. Browsers will prevent HTTP-only cookies from being accessed by JavaScript.
Before we make this change, let's first try to access the value of the session cookie with JavaScript in the browser's development tools.
In a new private browser window, let's sign in with the first user. We should be redirected to the dashboard page and see the message "Welcome [email protected]".
Let's now open the browser's development tools with Cmd + Option + I
and go to the Console
tab. We can then run the following JavaScript code to access the value of the user_id
cookie:
document.cookie.split(";").find(cookie => cookie.includes("user_id"))
// => user_id=eyJfcmFpbHMiOnsibWVzc2FnZSI6Ik1RPT0iLCJleHAiOiIyMDQ0LTA3LTI5VDEwOjQ3OjM3LjUxM1oiLCJwdXIiOiJjb29raWUudXNlcl9pZCJ9fQ%3D%3D--03ec036f73f17ae7e3f876b3f74541d8032f49d9
As we can see, we were able to access the value of the user_id
cookie with JavaScript. This is a security issue as an attacker could inject malicious scripts into the page and steal the session cookie.
Let's now make our cookie HTTP-only so that it can't be accessed by JavaScript:
# app/controllers/concerns/authentication.rb
module Authentication
# All the previous code
def sign_in(user)
cookies.signed.permanent[:user_id] = { value: user.id, httponly: true }
end
end
Let's close the private browser window and open a new one or simply clear the cookies manually from the current browser window. If we sign in again with the first user, we should be redirected to the dashboard page and see the message "Welcome [email protected]".
If we now reopen the JavaScript console from the dev tools and try to access the value of the user_id
cookie, we should see that it's undefined
:
document.cookie.split(";").find(cookie => cookie.includes("user_id"))
// => undefined
This means that the cookie is now HTTP-only and can't be accessed by JavaScript.
Session model
Let's now imagine the following scenario: a user contacts the support team because of suspicious activity on their account. The user remembers logging in on a public computer (at the airport, for example) and forgot to log out. How can we log out the user from all sessions?
If we are using the user_id
as the value we store in the cookies for authentication, it's going to be quite hard to log the user out as the user_id
is always the same for all sessions. As the session cookie exists on the public browser for the next 20 years, we can't really do anything about it.
To solve this issue, we can create a new Session
model where a user has many sessions and a session belongs to a user. We can then store the session_id
in the cookies. In order to authenticate a user, we can simply find the session by its id and check the associated user.
Let's go ahead and implement this model. We can generate a new model with the following command:
bin/rails generate model Session user:references
bin/rails db:migrate
The generated Session
model should look like this:
# app/models/session.rb
class Session < ApplicationRecord
belongs_to :user
end
Let's update our User
model to reflect the fact that a user can now have multiple sessions:
# app/models/user.rb
class User < ApplicationRecord
has_many :sessions, dependent: :destroy
# All the previous code
end
We can now simply update our Authentication
concern to create a new session when a user signs in:
# app/controllers/concerns/authentication.rb
module Authentication
def require_authentication
restore_authentication || request_authentication
end
def request_authentication
redirect_to new_session_path, notice: "You must be signed in to perform that action"
end
def restore_authentication
if session = session_from_cookies
Current.user = session.user
end
end
def sign_in(user)
session = user.sessions.create!
cookies.signed.permanent[:session_id] = { value: session.id, httponly: true }
end
def session_from_cookies
Session.find_by(id: cookies.signed[:session_id])
end
end
Let's break down this code together. When signing in a user, we create a new session associated with this user and store the session's id in the cookies. This means that each time the user logs in with a new device, a new session is created and stored in the database.
On the pages where authentication is required (such as the dashboard page in our example), we will try to find a session from the session_id
in the cookies. If a session is found, we set the Current.user
to the user associated with the session. You can try it in the browser and it should work exactly as it did before.
Now back to our airport computer scenario: how can we log out the user from all sessions? Well, that's very simple now. We can simply delete all the sessions associated with the user:
user = User.find(id_of_the_user)
user.sessions.delete_all
On the public computer, the old session id won't exist anymore, so the session_from_cookies
method will return nil. The user will be logged out from all sessions.
Note that some websites such as GitHub implement a feature to see all your active and inactive sessions and give you the ability to revoke them if needed. If you want to try with your GitHub account, you can visit the sessions index page at https://github.com/settings/sessions. This is a great feature to have in your application and can be implemented by simply listing all the sessions associated with the user and adding an action to delete them!
We have solved our public airport computer problem!
Conclusion
We did it! By successive iterations, we went from an insecure authentication mechanism to a secure one. We now have a secure way to authenticate users and we can easily log them out from all their sessions if needed.
In a real application, we might want to add additional information to the session such as the user agent, the IP address, the last activity date, etc. We could also allow users to revoke some of their sessions exactly like on GitHub.