Why Skipping Authentication Basics Can Break Your App
The authentication mistakes developers overlook that can compromise their entire application. Covers JWT cookies, access/refresh tokens, and sessions with code examples.
Everyone wants to build the next big app. But when it comes to authentication, most developers just plug in a library and hope for the best. Yes, this has already been discussed so many times, but still, a lot of new people in the engineering world do not understand simple authentication methods, and especially not how to integrate them. I’ve had some knowledge, but I never invested my time into those nor tried to implement them, but now I have, and it makes a lot of things much clearer and problems much more visible.
In this article, I will cover three basic but very important authentication methods that are being used:
Each of the methods is presented on a website, which I deployed just for learning and testing purposes, also there is a public GitHub repo. This shows how it was implemented. Both backend and frontend.
The Problem
In today’s web development landscape, it’s easier than ever to get authentication up and running. Just npm install a library, connect it to a provider, and you’re done, right?
But here’s the problem: most developers stop there. They never question what’s happening behind the scenes. And that’s where things start to break.
They then encounter risks like:
- Token leakage: If JWTs are stored in local storage (common mistake), they’re vulnerable to XSS.
- CSRF attacks: Improper cookie settings can allow attackers to exploit user sessions.
- Token reuse: Not rotating refresh tokens, which can open doors for replay attacks. Now, of course, each auth method also has its cost, and the developer picks which one he finds most acceptable, for example:
Stateless auth with JWTs is great for scaling across servers… but it comes at the cost of revocation control.
Sessions are easier to manage securely, but can become a bottleneck.
I will touch more deeply on those in my following implementation examples.
At the bottom line, authentication isn’t a “set it and forget it” feature; it’s a critical part of your app’s foundation. If you don’t understand the basics, you won’t know what’s going wrong when things break… and they will.
The Project
Now the project is a very simple one; it uses full JavaScript/TypeScript implementation for both the frontend and backend parts. NextJS is used for visual representation of the problem, and NodeJS/ExpressJS for the backend. The key thing to point out is that it does not matter which frameworks and language you use; the key emphasis is on authentication.
The application consists of three authentication methods:
-
Authentication with stateless JWT token
-
Authentication using access and refresh tokens
-
Authentication with a stateful approach using sessions
And the whole point of this project is to learn this key concept in depth and to help others try to understand and potentially implement their solutions themselves.
Let me now explain one by one…
Stateless authentication with JWT token stored in a cookie
This method is really simple and fast to implement; it powers most of the applications that need a simple and fast authentication system without state. Token itself can carry a lot of information, which is usually enough, and resources behind the token verification are protected, at least at some level!
Above is a simple representation of how the flow should look.
- User authenticates either via login or registration
- He receives a token via a cookie
- And a cookie with that token is being sent on each request, where it gets verified by the server
Now, the implementation in Node, to make it simpler, I will show the register flow and simple access to resources; more details can be found on GitHub.
Register
router.post("/register", async (req, res) => {
const LONG_EXPIRE_TIME = 1000 * 60 * 60 * 24 * 30;
const TOKEN_LONG_EXPIRE_TIME = 60 * 60 * 24 * 30;
const { email, password } = req.body;
// Salt and hash password
const hashedPassword = await bcrypt.hash(password, saltRounds);
const user = await createUser({ email, password: hashedPassword });
const token = jwt.sign({ email }, process.env.JWT_SECRET, {
expiresIn: TOKEN_LONG_EXPIRE_TIME,
subject: user.data.id,
});
return res
.cookie("token", token, {
// Only accessible by the web server
httpOnly: true,
// Only send cookie over HTTPS in production
secure: process.env.NODE_ENV === "production",
// 30 days
maxAge: LONG_EXPIRE_TIME,
// sameSite: "Strict",
})
.status(200)
.json({ data: { email: user.data.email }, error: false });
});
In this basic example whole implementation of token creation and sending to the client can be seen. User sends his credentials, server handles the password securely with salting and hashing, and saves the user information in the database. If all of that passes new token is signed.
Now, when signing a token, the JWT secret is used, and it creates symmetric token encryption, which, in simple terms, states that the same secret encrypts but also decrypts the token. That secret needs to be stored securely, as it is a crucial element to identify if the token is legitimate when being verified.
Other than the secret next important field is token expiration, as it also invalidates the token once it expires. Usually, in this authentication method, expiration is long, as we don’t want the user to log in constantly.
The subject is just being used to identify the user once they try to get protected resources.
The final part of this code snippet is cookie creation. Now you may have a question why cookie, why not local storage, or maybe you don’t even know what a cookie means. Well cookie is used to store some data; in this case, it stores token data. Server creates a cookie which is being sent back to the user, the user now has his cookie with a token stored in the browser, and on each request that cookie is sent to the server, where the server checks it and gets the user's token.
Now the cookie flags and what they protect:
httpOnly — demands that the cookie can only be accessible by the server, so XSS attacks are reduced because the cookie cannot be accessed from the client side. This is also a really good thing about cookies and why we should not store tokens in local storage, because local storage can always be accessed with JavaScript code in the browser, and cookies cannot.
secure — forces the cookie to be sent over https connection
maxAge — is the expiration time of the cookie
sameSite — means that the cookie must be sent from the same site it was received from, which prevents CSRF attacks
Finally, when the user tries to get protected resources server verifies the token, and if everything is good, the server returns the resources. In this case, express middleware was used.
// middleware.ts
export const checkToken = (req, res, next) => {
const token = req.cookies["token"];
const result = verifyToken(token, process.env.JWT_SECRET!);
if (result._tag === "Failure") {
console.error("<middlewares.ts>(checkToken)[ERROR] Verify Failed");
return res
.clearCookie("token")
.status(401)
.json({ data: "Token is invalid", error: true });
}
// If there is no user ID in the token, is invalid
if (!result.data.sub) {
console.error("<middlewares.ts>(checkToken)[ERROR] No user id in token");
return res
.clearCookie("token")
.status(401)
.json({ data: "Unauthorized", error: true });
}
// Proceed with the endpoint
return next();
};
// resources.ts
router.get("/resources", [checkToken], (_, res) => {
// Random resources
const resources = [
{ id: Math.floor(Math.random() * 100000) + 1, name: "Resource 1" },
{ id: Math.floor(Math.random() * 100000) + 2, name: "Resource 2" },
{ id: Math.floor(Math.random() * 100000) + 3, name: "Resource 3" },
];
res.status(200).json({ data: resources });
});
Problem with this authentication option
This option is great, it is simple, easy to implement, and protects the resources, but what is the problem?
Well, the problem lies in that stateless approach. Let's say the user authenticates, and now he has his token. But someone steals that token… now that someone can access anything because he has a valid token. There is no option to revoke the token as it was never stored (never saved to some state), and that is the problem. We can try to reduce the possibilities of stealing the token, but we cannot stop someone from using the token once it gets stolen!
Authentication with Access and Refresh tokens
An engineer's way of thinking after the first authentication approach would be “how can I revoke the token also?”. And here come access and refresh tokens. The basic principle is that we still want to keep all the good perks of tokens and cookies, but also add an extra protection layer.
In this case, we would have two tokens:
Access Token: Short-lived token, which is sent on each request to get protected resources
Refresh Token: A long-lived token, which is used to get a new access token when it expires. Also, a token that is stored somewhere to be revoked when needed.
Again simple diagram to show how it would work
Now I won’t put the code again as it is more complex, and the point is on how this method works, but again, everything is on GitHub.
Let's go again with registration. The user starts registration, and the same things happen as in the first approach, except for tokens. Two tokens are created. One is an access token, which is very short-lived (it can be seconds, or a few minutes), and the second token is a refresh token, which is long-lived (can be a week, a month, etc). The access token is returned in the response body and stored in memory on the client side, which is enough, as it is short-lived. The refresh token is returned in a cookie, as it was shown in the first example.
Why is the access token short-lived? The access token is short-lived to limit the damage if it’s stolen, since it’s used only to access protected resources and is more exposed during use.
On the other hand, the refresh token is stored somewhere server-side upon user registration. It is stored for the possibility of revocation if it is needed, also it is stored because when a new access token needs to be issued, the received refresh token on the server gets checked if it is the same as the stored one. If not, there is a problem, and the server stops issuing new access tokens.
For extra security, when the server issues a new access token, it also creates a new refresh token and overrides the old one. This just adds a touch if someone stole the refresh token, they can’t use it anymore.
Problems
The main challenges with this approach lie in its complexity and token management. It is important to ensure that refresh tokens are securely stored and protected, and that access tokens are not overly long-lived to minimize security risks.
Authentication With Session
This approach is purely stateful and also totally different from other ones. Because there are no tokens!
The flow of the session is pretty similar to one of a token in a first approach, but the session is stored in memory, which gives it a state.
Registration flow is really simple; the user registers again, and the server creates a new session in a cookie, which is sent to the client. That session gets stored in some memory, like for example Redis.
Here is an example of initializing a session on an Express server
app.use(
session({
secret: process.env.SESSION_SECRET!,
cookie: {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
maxAge: 1000 * 60 * 60 * 24 * 30, // 30 days
},
resave: true,
saveUninitialized: false,
})
);
A session uses a cookie to store the session ID, and it has its own secret key, which needs to be securely stored.
Resave option is just telling the server to resave the session on each request, which can lead to some race condition problems. Let's say two requests happen at the same time, but the first request finishes first, modifies the session, and stores it in memory. Second request did nothing with the session, but because of resave, it stores the old session it had over the newly updated one from the first request.
saveUninitialized is a flag that tells the server to save a newly created session to memory even if it isn’t modified.
To modify the session based on some request, data can simply be added to the session. That session is then stored in memory because it was modified.
req.session.userId = user.data.id;
req.session.email = user.data.email;
Problems
Sessions require careful security configuration, much like tokens, making them another potential attack vector. Unlike stateless JWT approaches, sessions must be securely stored server-side, which introduces additional security considerations for protecting that stored data.
A significant challenge emerges when scaling to multiple services; the centralized session storage becomes a bottleneck as every service needs to validate sessions against the same store. This creates both performance and reliability concerns, as the session store becomes a single point of failure that can bring down your entire system if it becomes unavailable.
Final Thoughts
I believe I’ve covered many important elements to consider before integrating any external library. My goal is to empower you to dig deeper and understand how that library handles authentication, because authentication is one of the most critical aspects of modern applications. A small mistake here can lead to unhappy users and serious security issues.
If you want to test the above solutions and maybe create your own, here are the links: