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:
-
The user enters their username and password.
-
The web browser submits the details to the server over a secure SSL/TLS connection.
-
The server retrieves the salt and hashed password for the user, and compares the supplied password against the hashed password.
-
The server generates a token and returns it to the client. The token is typically one of the following:
-
Session: A securely generated random number, set as a cookie.
-
JWT cookie: A JWT, returned as a cookie
-
JWT payload: A JWT, returned in the response
-
-
The user’s browser clears the username/password (so that it is not in memory) and saves the token
-
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");
}
});
});
JWT cookie
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:
-
Automatically, by the browser in the cookie
-
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"
}