Migrating to cross-domain cookies in Django

At StudyRoom, we recently launched a new site on a subdomain of our existing Django site, www.getstudyroom.com. The sites were to share authentication, so a user who went to books.getstudyroom.com shouldn't have to log in again if they were already logged into the main site.

HTTP cookies can be shared across subdomains, as long as they are set that way when they are originally created. Django provides the SESSION_COOKIE_DOMAIN setting to manage this.

The default value of SESSION_COOKIE_DOMAIN is None, which means that the domain that the cookie is set to is the full domain of the page that sets it. Unfortunately we left this setting at its default, not anticipating the need for cross-domain cookies. So our cookie domain is www.getstudyroom.com. For the cookie to be cross-domain, we want to set it to .getstudyroom.com.

But there's a problem with changing SESSION_COOKIE_DOMAIN once you have active sessions. When you change the domain, the existing session cookies remain with the old domain. Even if the user visits the site again and the new cookie is sent, it won't overwrite the old cookie with a different domain. In some cases, this can even result in users being unable to log in or log out. The official documentation does warn of this, but doesn't offer a solution.

We have no choice but to also change the name of the session cookie. Then the new cookie will be created with a different name and the correct domain. Django provides the SESSION_COOKIE_NAME settings to control this. But there's an even bigger problem with changing the session cookie name: all existing sessions will be lost!

In order to preserve existing user sessions, we need to write a custom middleware that can migrate session data from the old session cookie to the new one.

As always with custom middleware, we need to consider the ordering carefully. We need to put our session migration middleware after the Django session middleware, so we have access to request.session to migrate the data in. But we need to put it before any other middleware that will be using the session data, like the Django authentication middleware, so that it sees the session data after the migration is done. Here's an example of the proper ordering:

MIDDLEWARE_CLASSES = (
    # ...
    'django.contrib.sessions.middleware.SessionMiddleware',
    # ...
    'project.middleware.UpdateSessionCookieMiddleware',
    # ...
    'django.contrib.auth.middleware.AuthenticationMiddleware',
    # ...
)

Let's take a look at the middleware code itself. The first thing we need to do is check for the existence of the new session cookie in request.COOKIES. If it's there we don't need to do anything. Otherwise, we'll continue with the migration.

class UpdateSessionCookieMiddleware(object):
    def process_request(self, request):
        session_key = request.COOKIES.get(settings.SESSION_COOKIE_NAME, None)
        if session_key is None:

We then check for the existence of the old session cookie in request.COOKIES. I've hardcoded the value here because settings.SESSION_COOKIE_NAME has already been changed to the new one. If the old one doesn't exist either, then we stop and let the new, empty session stay. Otherwise, we'll instantiate a SessionStore with the old session key. This will be an object just like request.session, except it's backed by the old session cookie.

            old_session_key = request.COOKIES.get('sessionid', None)
            if old_session_key:
                engine = import_module(settings.SESSION_ENGINE)
                old_session = engine.SessionStore(old_session_key)

Since our middleware is after the Django session middleware, request.session exists and is backed by the new session cookie. So we can simply iterate over each key and value in the old session and copy it over to the new session. We also have to make sure to call request.session.save() to make sure our migrated data is saved to the database.

                for key, value in old_session.items():
                    request.session[key] = value
            request.session.save()

Finally, we need to clean up after ourselves by deleting the old session cookie. This is important because if we don't, the old session cookie stays even if the user logs out of the new session, which could potentially trigger another migration, causing the user to be logged right back in.

We can't delete a cookie from the HttpRequest, so we have to do it from the HttpResponse in process_response instead. The old session cookie name needs to be hardcoded, as does the old session cookie domain to make sure the browser finds the right one.

    def process_response(self, response):
        response.delete_cookie('sessionid', domain='www.getstudyroom.com')

You can see the complete middleware code on GitHub: https://gist.github.com/pindia/9b7787a607a5a676c6d8

Thanks to Austin Phillips, whose Stack Overflow answer inspired my solution.