§9.6.

Handling login

There is a lot to consider in the OWASP top ten. It can be overwhelming. Many of these issues relate, in some way, to the handling of the login form in an application.

Don’t let it be a cause for stress. Start simple and then gradually build up to something more sophisticated.

Login flow

The typical login should follow five stages:

  1. The user enters their username and password.

  2. The web browser submits the details to the server over a secure SSL/TLS connection.

  3. The server retrieves the salt and hashed password for the user, and compares the supplied password against the hashed password.

  4. The server generates a token and returns it to the client. The token is typically one of the following:

    1. Session: A securely generated random number, set as a cookie.

    2. JWT cookie: A JWT, returned as a cookie

    3. JWT payload: A JWT, returned in the response

  5. The user’s browser clears the username/password (so that it is not in memory) and saves the token

  6. With each subsequent request, the browser sends the token to the server as ‘proof’ that the user has logged in. The token represents a session so that there is no need to resend the username/password with every request.

When you implement this login flow, you should consider using standard libraries or frameworks such as express-session or Passport.js.

You might also choose to use a commercial identity provider service, such as Auth0.

Session

Sessions are a historically popular approach to keeping track of user-specific state in web applications. A session is a user-specific data store that is automatically managed by the server. When the server creates a new session, the server generates a token to reference the data-store. The server sends the token to the user in a cookie.

The Express project has the express-session package to assist with sessions.

The express-session middleware automatically creates a req.session object that can be used to store any data. For example, in the code below, req.session.view_counter is used to store the number of times the page has been viewed.

const express = require('express');
const session = require('express-session');
const app = express();
const port = 3000;

// Use the session middleware
app.use(session({
    cookie: {
        httpOnly: true,
        sameSite: 'strict',
        secure: 'auto'
    },
    resave: false,
    saveUninitialized: false,
    secret: '...change this to a unique secret key...'
}));

// Access the session as req.session
app.get('/', (req, res)  => {
    if (req.session.view_counter)
        req.session.view_counter++
    else
        req.session.view_counter = 1;

    res.send(`<!DOCTYPE html><title>Counter</title>
              <p>You have visited ${req.session.view_counter} times.</p>`);
});

app.listen(port, () => console.log(The counter is running on http://localhost:${port}/));

Storing the value of view_counter in req.session triggers the creation of a new session. The express-session middleware then automatically generates a secure token and returns it in a cookie. The following is an example of the typical HTTP response automatically generated by Express with express-session — the token is set as the cookie value connect.sid.

HTTP/1.1 200 OK
X-Powered-By: Express
Content-Type: text/html; charset=utf-8
Set-Cookie: connect.sid=s%3A9TZvIRKk2O_UPegbVd71oect6dI55KGJ.uB8%2B3gzZttZlVwuvUoZhpSdoOhKOVcDqQsB6JHo04eI; Path=/; HttpOnly; SameSite=Strict
Date: Sat, 01 Jan 2000 10:20:30 GMT
Content-Length: 84
Connection: keep-alive

<!DOCTYPE html><title>Counter</title>
<p>You have visited 1 times.</p>

Notice the two options httpOnly and sameSite in the session configuration and the Set-Cookie header. The httpOnly option ensures that the cookie is only accessible by the browser’s internal code: the browser does not allow the cookie values to be passed to any JavaScript code. In other words, the browser provides a secure container to help prevent rogue JavaScript from stealing the cookie. The sameSite option stops the browser from sending cookies in cross-site requests. [1] [2]

To complete the security of session-based approaches, it is important to ensure a brand-new session is started when a user logs in. [3] This is achieved by calling res.session.regenerate(callback) as in the following example:

app.post('/login', (req, res)  => {
    // Check username/password
    ...
    ... <handle password verification logic>
    ...

    req.session.regenerate((err) => {
        if (err) {
            // An error occurred
            res.send("Could not create session");
        } else {
            // A brand new, blank session has been created
            req.session.loggedIn = true;
            res.send("You have logged in");
        }
    });
});

A disadvantage of sessions, such as express-session, is that the server must store the session data. It may be preferable to use JWT to validate session data stored in the end-users’ web browser.

The basic principle is similar to how a session works: the server generates a token as a cookie for storage by the user’s browser. The difference is that the token is not a randomly generated identifier referencing a server data-store. Instead, the token is a signed JWT with a payload and an expiration date.

The following code illustrates JWT cookie authentication without any framework.

const express = require('express');
const cookieParser = require('cookie-parser');
const jwt = require('jsonwebtoken');
const app = express();
const port = 3000;

// Automatically read the Cookie headers and store in req.cookies
app.use(cookieParser());

const secret = '...change this to a unique secret key...';

// This is a simulated login page
// In production, it should be a POST rather than a GET request
app.get('/login', (req, res)  => {
    // For this demo, I'm bypassing the password check
    // I assume the "Example" user is successfully logged in
    let payload = { username: "Example" };

    // Create a JWT from the payload
    // The JWT is signed with 'secret'
    // it will only be valid for 30 minutes
    let token = jwt.sign(
        payload,
        secret,
        { expiresIn: '30 minutes' }
    );

    // Store the JWT in a HTTP-only same-site cookie
    res.cookie('jwt', token, {
        httpOnly: true,
        sameSite: 'strict',
        secure: true
    });

    // Send the response body
    res.send('You have logged in');
});

// This is a demonstration of the verification logic
app.get('/check_login', (req, res) => {
    try {
        if (req.cookies.jwt) {
            // Check that the supplied JWT is:
            // 1. In the correct format
            // 2. Correctly signed with 'secret'
            // 3. Not expired
            let payload = jwt.verify(
                req.cookies.jwt,
                secret
            );
            res.send(`You have logged in as ${payload.username}`);

        } else {
            res.send('You are not logged in');
        }
    } catch (e) {
        res.send('Your JWT is invalid or expired. Log in again.');
    }
});

app.listen(port, () => console.log(`The counter is running on http://localhost:${port}/`));

Notice that in this code, jwt.verify does not need to perform any login, database or other authentication checks. It uses the digital signature formed from the secret value to validate the token.

Like session-based authentication, this code depends on the httpOnly and sameSite cookie options to protect against cross-site request forgery. For modern browsers, this is fine. In older browsers that do not support the sameSite option, it is possible to use the ‘double-submit pattern’:

  • The server provides the JWT (or a session token) in a cookie (named XSRF-TOKEN)

  • The JavaScript code reads the cookie (only ‘same-site’ JavaScript is allowed to read cookies)

  • The browser sends two copies of the token in subsequent requests:

    1. Automatically, by the browser in the cookie

    2. By the JavaScript code in a separate X-XSRF-TOKEN header

Angular has built-in support for this approach. It can also be managed automatically in React, using defaults in Axios [4]. Slightly more work is required when working with fetch. On the server, you can use the Express csurf middleware.

JWT payload

Sessions and JWT cookies both depend on the token being stored as cookies by the browser. If used with httpOnly and sameSite, this avoids many common vulnerabilities. However, in some situations, it is useful to return the JWT directly. For example, when devices other than web browsers use your server API, or when your users have configured their browser to reject all cookies.

In this case, a successful request to a /login endpoint would return the JWT directly. For example, the response might be the following JSON:

{
    "authToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.e30.LwimMJA3puF3ioGeS-tfczR3370GXBZMIL-bdpu4hOU"
}

In subsequent requests, set the value of the authToken in an HTTP header:

POST /check_login HTTP/1.1
Host: localhost
Accept: application/json
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.e30.LwimMJA3puF3ioGeS-tfczR3370GXBZMIL-bdpu4hOU

{
    "example": "data",
    "another": "example"
}

Or, set the value in the request body:

POST /check_login_body HTTP/1.1
Host: localhost
Accept: application/json

{
    "authToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.e30.LwimMJA3puF3ioGeS-tfczR3370GXBZMIL-bdpu4hOU"
    "example": "data",
    "another": "example"
}
Reflection: Benefits and weaknesses

In this section, I discussed three approaches:

  1. Sessions

  2. JWT cookies

  3. JWT payload

What are the strengths and weaknesses of each?


1. Browsers have supported HttpOnly since 2010 and SameSite since 2018. If you wish to protect users of older web browsers, then cross-site request forgery tokens must be embedded in HTML forms.
2. Note that the HttpOnly option only works in secure contexts (i.e., over TLS/SSL).
3. Creating a new session will avoid session fixation attacks.
4. axios.defaults.headers.post['X-CSRF-Token'] = token;