Secure Session Management Tips

Most (if not all) modern websites use sessions to control the experience for individual users, and to maintain state between requests (since HTTP is a stateless protocol after all). Sessions are fantastic and incredibly useful, but if managed incorrectly they can expose your website to security vulnerabilities and potentially allow a malicious attacker to gain unauthorised access to user accounts.

Of course, the biggest tip is that you should really just use a pre-built framework which has tried and tested session management code where security experts have tested and verified it, and the bugs have been identified and fixed. But I never listen to myself...I've been building a new site in my spare time recently and got to the point of writing the session management code. This seemed like a good subject to try and get myself into the habit of updating my notes more regularly.

So while certainly not an exhaustive list, here are 11 of my tips on managing sessions and avoiding some common security vulnerabilities (yes, this post goes all the way to 11). I'm using PHP in the code examples, but the principles apply to any other language. In fact, PHP does a very good job of automatically protecting against most of the attacks the tips discuss, but this isn't necessarily the case for other languages, so the principles are still important to understand.

1. Always regenerate a session ID (SID) when elevating privileges or changing between HTTP and HTTPS.


By elevating privileges, I don't mean when you modify a user's record to give them more permissions, I mean any time an action is performed which makes the current user of the website have more privileges than they had moments before. Things such as logging in, where the user now has more privileges than they did a moment ago yet are still the same user of the website (and will be on the same session). In this case you should immediately generate a new session ID for them and destroy their previous session.

Regenerating a SID is extremely important to protect against session fixation. To understand what session fixation is, consider the following example of horizontal privilege escalation. User M (malicious) is a malicious user who is trying to access the account of User V (victim) on a website.

User M: Visits the website, and has a session ID assigned to them. They either look at the GET parameters (?sid=xxxxx) or at the headers (Set-Cookie: sid=xxxxx) to determine their session ID. Once they have the ID, they craft either a direct link using the GET parameters (http://example.com/?sid=xxxxx) or they construct a non-direct link which will add the relevant headers. This link is then sent to User V.

User V: Gets an email from User M which says "Hey, check out your new look banking account! http://example.com/?sid=xxxxx" Since the link looks legitimate (example.com is the real URL) then the user clicks the link confident it's not a scam. They then login with their account.

User M: Since this user already had the same session or already knows the SID, they will now be logged in to the site as if they are user V. If you regenerate the SID, then this wouldn't be possible since user M would no longer know the correct SID.


There are other ways session fixation can be performed, such as setting a cross-site cookie (Site A sets a cookie for Site B with the session ID, etc), or if you allow a sid to be set from a GET/POST variable then the malicious user can just pick one they want and don't even have to visit in the first place.

Remember it's not just for login, any privilege escalation should get a new session ID. You could just do it on every request since the user won't care that the session ID changes and it makes any session ID immediately invalid after the next request, but that's overkill and doesn't offer any real security benefit. Regenerating the session ID will make the attack useless, since by the time User V has done anything, the session ID they were sent was invalid, so User M cannot access their account.

You should also regenerate the SID when switching between HTTP and HTTPS, since you want to be sure not to use an insecure value over a new secure connection and vice versa.

In PHP it's easy to regenerate a session id using session_regenerate_id(), just remember to take extra care to always use true as the optional parameter so that the old session file gets deleted.
session_regenerate_id(true);


2. Check for suspicious activity and immediately destroy any suspect session.


You want to be sure the user who started the session is the same as the user who is actively using the session. There is no 100% accurate way to do this, the best you can do is to look for suspicious signs and get the user to re-authenticate if you suspect at any point they're not the same user.

This is to help prevent session hijacking. Suppose a user visits your site and comes from IP address 999.999.999.999 and is using the Chrome browser. Then all of a sudden on the next request they visit from 888.888.888.888 and are using the Internet Explorer browser. This would be pretty unlikely to happen if it were the genuine user, and so can be considered suspicious. You should actively monitor for these types of events and immediately destroy the user session and/or get them to re-authenticate.

So it's simple then, just check if the IP changes and we can be safe knowing we're protected? WRONG. Many people can be using the same IP address and this alone doesn't prevent a malicious user from hijacking a session.

Ok, so we just need to check the User Agent and if it changes then we're protected? WRONG. Changing the user agent header is trivial and should not be relied upon to protect against this attack.

So what's the answer? There isn't one (that I'm aware of). There's no foolproof way to positively determine that the user is the same user who started the session. We can only be suspicious that a particular user isn't the original user. That's what this tip is about, not knowing the positive, but potentially knowing a negative and acting on it pre-emptively.

So if the user agent changes and the IP changes, you should implement some sort of policy to re-generate the session and get the user to re-authenticate. Be careful about just implementing one of these though, as it can have some bad side effects. Users behind a proxy will find their IP changes on every request, and the user agent string is easy to change manually. It depends on how cautious you want to be and what the site is used for. There's no right answer here.

Some guides and tutorials will suggest also checking the referer to make sure it came from your own site. This would seem to be completely useless to me though since it can be easily spoofed by an attacker, and in some cases might not even be sent at all. Referer will give you more false negatives than is necessary and will just degrade the experience for your users.
If ($_SESSION['_USER_IP'] != $_SERVER['REMOTE_ADDR']
    || $_SESSION['_USER_AGENT'] != $_SERVER['HTTP_USER_AGENT'])
{
    session_unset(); // Same as $_SESSION = array();
    session_destroy();
    session_start();
    session_regenerate_id(true);
    Log::create("Possible session hijacking attempt.", Log::NOTIFY_ADMIN)
    Auth::getCurrentUser()->reAuthenticate(Auth::SESSION_SUSPICIOUS);
}

$_SESSION['_USER_IP']    = $_SERVER['REMOTE_ADDR'];
$_SESSION['_USER_AGENT'] = $_SERVER['HTTP_USER_AGENT'];


A loose IP check is a good option if you don't want to screw over proxy users. Just check the first 2 blocks of the IP address. It will catch anyone quickly changing countries for example. You can add even more information into the mix too, such as if the "Accept" headers change, since these will generally stay the same if it's the same user.
If ($_SESSION['_USER_LOOSE_IP'] != long2ip(ip2long($_SERVER['REMOTE_ADDR']) 
                                           & ip2long("255.255.0.0"))
    || $_SESSION['_USER_AGENT'] != $_SERVER['HTTP_USER_AGENT']
    || $_SESSION['_USER_ACCEPT'] != $_SERVER['HTTP_ACCEPT']
    || $_SESSION['_USER_ACCEPT_ENCODING'] != $_SERVER['HTTP_ACCEPT_ENCODING']
    || $_SESSION['_USER_ACCEPT_LANG'] != $_SERVER['HTTP_ACCEPT_LANGUAGE']
    || $_SESSION['_USER_ACCEPT_CHARSET'] != $_SERVER['HTTP_ACCEPT_CHARSET'])
{
    // Destroy and start a new session
    session_unset(); // Same as $_SESSION = array();
    session_destroy(); // Destroy session on disk
    session_start();
    session_regenerate_id(true);

    // Log for attention of admin
    Log::create("Possible session hijacking attempt.", Log::NOTIFY_ADMIN)

    // Flag that the user needs to re-authenticate before continuing.
    Auth::getCurrentUser()->reAuthenticate(Auth::SESSION_SUSPICIOUS);
}

// Store these values into the session so I can check on subsequent requests.
$_SESSION['_USER_AGENT']           = $_SERVER['HTTP_USER_AGENT'];
$_SESSION['_USER_ACCEPT']          = $_SERVER['HTTP_ACCEPT'];
$_SESSION['_USER_ACCEPT_ENCODING'] = $_SERVER['HTTP_ACCEPT_ENCODING'];
$_SESSION['_USER_ACCEPT_LANG']     = $_SERVER['HTTP_ACCEPT_LANGUAGE'];
$_SESSION['_USER_ACCEPT_CHARSET']  = $_SERVER['HTTP_ACCEPT_CHARSET'];

// Only use the first two blocks of the IP (loose IP check). Use a
// netmask of 255.255.0.0 to get the first two blocks only.
$_SESSION['_USER_LOOSE_IP'] = long2ip(ip2long($_SERVER['REMOTE_ADDR']) 
                                      & ip2long("255.255.0.0"));


3. Store all session information server-side, never store anything except the SID in the client-side cookie.


A friend of mine (OK... me) built a site a very long time ago and thought that storing the username and password in a cookie on the client-side was the correct way to do things. Those details were then authenticated again on each request. As it turns out, this is a very bad idea. For starters, cookies are generally stored in plaintext on the client-side, so anyone with access to the computer can see them. Secondly, there are many attacks which can steal cookies and then use the information to impersonate another user. If all an attacker gets is a session id which has probably been regenerated since, it's pretty useless.

I... I mean my friend then decided that it might be better to hash the password. But again they were wrong. Even if you hash the password, it shouldn't be stored client-side, EVER. It allows someone to brute force the password and have all the time in the world to do it. Basically, never trust the client, you can't rely on client-side information being accurate, you should always keep things server side where you know that they are accurate.

The worst example of this was something I saw a few years ago (not me this time, thankfully). A site would start a session and let you login. The cookie would have a flag which said if the user was logged in or not, and then another variable with the username. All state was stored in the cookie rather than in the session. All you had to do to login as a different user was to change the username in the cookie, no password needed. A cookie can always be manipulated by the user.

Store all information server-side and only store the session ID on the client-side. The cookie should just be a pointer to the information server-side. You should treat cookies in the same way as any other user input (validate it and sanitize it).

When setting your cookies remember to always specify the domain, an expiry, and set the "HttpOnly" and "secure" options. "HttpOnly" prevents JavaScript from accessing the cookie, only the server can access it (assuming the user's browser implements it correctly of course). A common method of stealing cookies (and hence the session ID) is to inject some JavaScript onto a site using XSS, and then this JavaScript will steal the user cookie and post it to a malicious domain where the information is collected. Adding HttpOnly helps to prevent this, as the cookie can only be accessed via the HTTP protocol (this includes HTTPS, HttpOnly doesn't mean "unsecure HTTP only"). Setting the "secure" flag will limit the cookie so that it can only be accessed over a secure connection using HTTPS.

As of PHP5.2 you can specify "HttpOnly" and "secure" in the setcookie() method as the last parameters, or you can just set them directly into your PHP configuration to have session_start() make use of them.
// Manually set the cookie
setcookie("sid",                // Name
          session_id(),         // Value
          strtotime("+1 hour"), // Expiry
          "/",                  // Path
          ".wblinks.com",       // Domain
          true,                 // HTTPS Only
          true);                // HTTP Only

// Or, in php.ini
session.cookie_lifetime = "3600";      // Expiry
session.cookie_httponly = "1";         // HTTP Only
session.cookie_secure = "1";           // HTTPS Only
session.cookie_domain = ".wblinks.com" // Domain

// Then session_start will use the above config.
session_start();


4. Confirm SIDs aren't from an external source, and verify the session was generated by your server.


Never just blindly accept a session ID and assume it's valid. If you grab a session ID from a cookie, confirm that the cookie was set by the domain of your website (and not an invalid sub-domain for example), and make sure the session exists already and was generated by your server (so don't allow users to set their own session ID). We tend to assume that browsers correctly handle cookies so that cross-site cookies aren't possible. This might not be the case for all the browsers that your users use though, which can allow cross-site cooking. If the domain is invalid or the session wasn't created by your server, then destroy the session immediately and regenerate a fresh one.

Checking the session was generated by your server is as simple as adding a value into the session variable and checking for it's existence.
If (!isset($_SESSION['MY_SERVER_GENERATED_THIS_SESSION']))
{
    session_unset(); 
    session_destroy();
    session_start();
    session_regenerate_id(true);
}

$_SESSION['MY_SERVER_GENERATED_THIS_SESSION'] = true;


5. Don't append the SID to URLs as a GET parameter.


Not really an issue in PHP 5.3.0 and later, since the default configuration of session.use_only_cookies will protect against this, but it could be important for another language or earlier PHP version.

Doing this will leak the SID to various people, and SID leakage can lead to a session fixation attack if you haven't protected against it (see Tip 1). It will be stored in the user's browser history, it will be stored in a bookmark if they bookmark the page. If they copy/paste the link then it will be copied too. Use a cookie instead. This isn't a huge deal if you regenerate the id on every request, but still something to avoid.

Cookies are very rarely disabled nowadays and I've yet to see anyone (in person, or in logs) who visits without them enabled. I'm all for having a fallback, but you need to decide if it's worth it based on site traffic.


You should expire a session after both an overall lifetime and an inactivity time. Make sure you mark when a session was last used on every request. Just add a session variable with the current time,
$_SESSION['_USER_LAST_ACTIVITY'] = time();


When starting the session you should also store the time and then check a longer delay to make sure the session cannot last too long.
$_SESSION['SESSION_START_TIME'] = time();


Before doing any of this though, check if the last activity or start time value is older than some pre-defined time limit. If so, then destroy the session immediately. While adding a cookie time limit is important too, it should not be relied upon. You can't just clear the cookie on expiry and think it's over. The session will still be active on the server side and the session ID can still be used. You must clear it server-side too.

When the session expires you should make the user login again, and regenerate the SID as per Tip 1.
if ($_SESSION['SESSION_START_TIME'] < (strtotime("-1 hour"))
    || $_SESSION['_USER_LAST_ACTIVITY'] < (strtotime("-20 mins")))
{
    session_unset();
    session_destroy();
    Auth::getCurrentUser()->reAuthenticate(Auth::SESSION_EXPIRED);
}


7. Use long and unpredictable session IDs.


Quite basic this one, but never use sequential session ID's! If you rely on using a session ID which increments every time you need a new one, stop immediately and re-think your strategy. Even if you regenerate your session IDs to prevent fixation, even if you don't allow the session ID to be given as a GET parameter, even if you don't leak it in a GET parameter, none of that matters if you have predictable session IDs as an attacker can just know your current session ID no matter what. Session prediction is very bad.

In PHP this is all taken care of for you, and is not something you need to be concerned about, but in other languages that might not be the case.
session_start();
session_regenerate_id(true);


You can configure PHP a bit deeper than the defaults and decide on the entropy source (session.entropy_file) used to create the session IDs (/dev/random, etc), the hash function used (session.hash_function), how many bytes are used (session.entropy_length), etc. Change these if you like, but the defaults usually suffice.

Don't try to get clever and generate a session ID based on a hash of the IP or user-agent or anything like that. That's what I mean by predictable data, if you can find the pattern then an attacker can generate a session ID for anyone, opening you up to session hijacking.

Use something as random as possible, but also make sure it's actually a good pseudo random generator. Don't make the mistake of assuming the PHP rand() method on Windows is good for randomness for example, because you'd be surprised.

8. Properly sanitize user input before setting headers with them.


PHP will automatically protect against this as it will only allow one header to be set in the header() function, but for other languages it may not be the case.

This might seem a strange tip and appear unrelated to sessions, but all will become clear after an example. Suppose you have the following code, where the sanitize function strips things like HTML/JavaScript/SQL to prevent cross site scripting, but doesn't strip CRLFs (carriage return and line feed, or 0x0D and 0x0A in ASCII).
$nextPage = Sanitizer::sanitize($_GET['next_page']);
header("Location: $nextPage");


Even though it seems like you're protected from XSS attacks, session fixation is still possible using this method, despite the fact that you've technically not allowed the user to set sessions via the URL. This would appear to have nothing to do with sessions, but suppose I give the following link to a user,
http://example.com/?next_page=login%0d%0aSet-Cookie:%20sessionID%3d12345678


If you get rid of the HTML encoded characters you get,
?next_page=login\r\nSet-Cookie: sessionID=12345678


A sanitize method which doesn't protect against this by removing CRLFs will allow the above string through, in which case the following header has just been sent,
Location: login
Set-Cookie: sessionID=12345678


So even though you never allowed users to explicitly set the session ID, and you're sure you're safe against session fixation, a seemingly unrelated bit of code has allowed a sessionID to be fixed. An attacker can also modify the headers to make sure the cookie never expires for example, making it more likely the attack will succeed. This type of attack is called HTTP response splitting and is not something PHP users need to be too concerned with, as header() only allows one header to be set at a time, purposely to prevent this type of attack.

If you're not using PHP, you should sanitize any input which sets headers by removing CRLFs to prevent response splitting.

9. When a user logs out, destroy their session explicitly on the server.


Don't rely on garbage collection to destroy the session information on disk for you after a user logs out. Garbage collection may never run on a slow traffic site, even if you've set session.gc_probability, session.gc_divisor and session.gc_maxlifetime up properly. You can never absolutely guarantee garbage collection will run when calling session_start(). Always manually use session_destroy() to end the session and delete the data from disk. Don't rely on a cookie expiry to do it for you either, since if you don't manually destroy the session it will still be available on the server.
session_unset(); 
session_destroy();
session_start();
session_regenerate_id(true);


It's not generally possibly to delete a cookie explicitly, instead you need to re-set a cookie with the same name, but set it's expiry time to the past. I've seen tutorials which suggest using some JavaScript to clear the cookies, don't do this. Firstly relying on JavaScript is a bad idea, but also if you're storing your cookies correctly in the first place with HttpOnly (see Tip 3) then it shouldn't be possible to access your cookies via JavaScript anyway.

So first, here are some ways you shouldn't clear cookies.
unset($_COOKIE);
// This will only remove it from the superglobal and will do nothing to
// the actual client-side cookie.

setcookie("sid", "", 0);
// 0 sets the expiry time to when the browser is closed and doesn't 
// immediately expire it. Don't use 0!

setcookie("sid", "", strtotime("-1 hour"));
// Sets the expiry to one hour in the past right?
// In server time yes, but cookies are stored on the client in their
// local timezone, so depending on where that is, it may not expire for a
// few more hours!


The correct way to clear a cookie is to just pass in 1 as the expiry time. This is one second after the unix epoch and will always be in the past. (If you really want you can set it to some time over 24 hours in the past, but "1" is always going to be less verbose).
setcookie("sid", "", 1);


Don't forget to destroy the session on the server-side too!

10. Check your session configuration.


Check your session configuration carefully to ensure you're not sharing things you shouldn't. For example, by default PHP stores sessions in the "/tmp" directory. All well and good if you have a dedicated server, but if you're on shared hosting then it could allow anyone else on that server to see the session data and hijack them. Of course, you can still use /tmp, just make sure to set the file system permissions properly so only you can read the session data.

It's recommended to read through all of the configuration options PHP or the language of your choice provides and to make sure they're all set up correctly for your needs. A slight misconfiguration can open you up to all sorts of strange attacks. In general the default configuration is pretty good, but there are still some things you should consider changing (like the session.save_path mentioned above for example). The parts of the configuration you need to change will always depend on the specific needs of your application, so make sure to understand all of the options available to you.

11. Force users to re-authenticate on any destructive or critical actions.


A quick tip to end on. Any time a user wants to perform something destructive or critical (delete account, change password, etc) then you should force them to re-authenticate with their password. This will prevent anyone from performing the critical actions if they've stolen a valid session ID since they don't know the password.
Auth::getCurrentUser()->reAuthenticate(Auth::ACTION_SENSITIVE_CRITICAL);


You don't have to worry about the malicious user knowing the password, since if they knew that then it's a moot point and game over anyway, attempting the hijack the session would be pointless.

Summary


  1. Always regenerate a session ID (SID) when elevating privileges or changing between HTTP and HTTPS.
  2. Check for suspicious activity and immediately destroy any suspect session.
  3. Store all session information server-side, never store anything except the SID in the client-side cookie.
  4. Confirm SIDs aren't from an external source, and verify the session was generated by your server.
  5. Don't append the SID to URLs as a GET parameter.
  6. Expire sessions on the server side, don't rely on cookie expiration to end a user session.
  7. Use long and unpredictable session IDs.
  8. Properly sanitize user input before setting headers with them.
  9. When a user logs out, destroy their session explicitly on the server.
  10. Check your session configuration.
  11. Force users to re-authenticate on any destructive or critical actions.


None of this is cutting edge and there are no new session based attacks out there that have prompted this post, all of these tips are things that have been known for years. But that doesn't mean people aren't always started to learn about web development and these things need to be known. Even if you rely on pre-built frameworks, knowing this stuff is useful for other areas.

As I said at the start, the list is certainly not exhaustive and there are plenty of excellent tutorials and articles on the subject just a Google search away. Secure session management is a complicated subject, so it's well advised to read around before trying to implement your own system.

As I have said many times in past notes, I am not a security expert. Before trying to write any session management code yourself, seriously consider using something pre-built and open source. Many web frameworks have session management abilities as part of them which have been tried and tested by many users and security experts, people who are much smarter than me.
Picture of Rich Adams.

Hi! I'm Rich. By day I work on cloud security at Indeed. By night I wear a cape and fight crime1. I'm on Twitter as @r_adams, and also used to write things on the PagerDuty blog.

1 probably not true

Additional Reading

Other articles/posts on similar subject matter (some of these may be more recent than this one),

References

A list of all the links that appear in this note,