While seemingly simple, I see this question often, so I'm sharing my auth setup here, and how you could improve on it.
There are 4 parts to this: register, login, authentication, and logout.
Your register route takes a username & password, hashes the password and stores in the db. For reference, this would be a very minimal user schema:
type Roles = "USER" | "ADMIN";
type Session = {
// you could store additional information about login time, useragent, etc
token: string;
}
type User = {
email: string;
password: string;
role: Roles;
sessions: Session[];
// ...
}
When the user logs in for the first time, a session token is created and pushed to the sessions array (you can rotate the array so there's always a maximum number of sessions per user). This token is also sent to the client as a httpOnly cookie set to expire in, let's say a week. Additionally, Secure
should be set in production only.
Set-Cookie: Authorisation=a3fWa...; Expires=Thu, 21 Oct 2021 07:28:00 GMT; Secure; HttpOnly
When making API requests with fetch, set credentials: "include"
, or withCredentials: true
if using axios. The browser will automatically include the token when making requests.
On your /logout
route, you simply unset the cookie we set before and delete it from the db.
Now for the juicy part. Authentication on other routes. If using express or similar http library, we can have a middleware similar to this:
function auth (roles: Roles[]): Handler => async function (req, res, next) {
const token = req.cookies["Authorisation"];
if (!token) {
res.status(401);
return res.send({ msg: "You're not logged in." });
}
const user = await database.User.findOne({ "sessions.token": token });
if (!user) {
res.status(401);
return res.send({ msg: "Malformed token" });
}
if (roles.includes(user.role)) {
// All good
res.locals.user = user;
next();
} else {
// User doesn't have necessary role
res.status(403);
res.send({ msg: "No access to this resource" });
}
};
Now we simply use this middleware like so:
app.post("/api/article", auth(["ADMIN"]), (req, res) => ...);
If the user isn't signed in, or doesn't have the admin role, they'll be turned away.
Yes, we could, if you think the benefits are worthwhile. You could wrap the token in a JWT before setting as cookie. This would let us verify if the token is valid at all, without having to look up the database. It has the small benefit that an attacker trying to access a user by bruteforcing a token will be unable to sign his token.
But in my experience, it's unlikely we ever need to process a request without looking up the user object. You could enclose more user data in the JWT, for example user.role
, but we lose the ability to change user role in realtime. You'd need to wait for the user to logout and back in, or for the token to expire and they're forced to log in again. So JWT will add a layer of security to your application, but if you agree with me so far, it wouldn't be your only way to authenticate. But read on.
I'm glad you asked. Because while JWTs are great for verifying identity statelessly, it's a poor solution to auth. At any time, the server has no knowledge of how many sessions are "logged in", it's impossible to forcefully log out a (compromised?) user session without changing the JWT secret, which logs everyone out.
A user wouldn't be able to look at his sessions and delete them. User data stored in the JWT won't be realtime, and will need a relogin or token refresh to update. Even user logouts would be a lie, because it only deletes the JWT token from the client, while it's still valid. If an attacker somehow owned this token, he'd have unfettered access until it expires.