Authentication is one of those topics I underestimated early in my career. I thought “just check a username and password” — and then I spent years learning why that sentence hides an enormous amount of complexity: sessions, tokens, hashing, refresh flows, CSRF, XSS, and a dozen edge cases that only show up once real users (and real attackers) touch your app. In this article, I want to share everything I’ve learned about implementing authentication in JavaScript applications, from the fundamentals up to production-grade patterns.
What Authentication Actually Means
Authentication answers the question “who are you?” — as opposed to authorization, which answers “what are you allowed to do?” I mention this distinction upfront because I’ve seen so many bugs come from conflating the two. A user can be authenticated (logged in) but not authorized (not allowed to access a specific resource).
The Building Blocks
Before writing any code, there are a few JavaScript and web-platform fundamentals worth understanding:
- HTTP is stateless. Every request is independent unless you explicitly attach identifying information (a cookie or a token).
- Cookies are sent automatically by the browser on every request to a matching domain, which is convenient but also why CSRF protection matters.
- localStorage/sessionStorage are not sent automatically, but anything stored there is readable by any script running on the page, which is why XSS matters so much for token-based auth.
Password-Based Authentication: The Basics
Let’s start with the classic username/password flow using Node.js on the backend.
const bcrypt = require('bcrypt');
async function registerUser(username, plainPassword) {
const saltRounds = 12;
const hashedPassword = await bcrypt.hash(plainPassword, saltRounds);
// Store username and hashedPassword in the database
return { username, hashedPassword };
}
async function verifyPassword(plainPassword, hashedPassword) {
return bcrypt.compare(plainPassword, hashedPassword);
}
I never store plaintext passwords — ever. bcrypt (or argon2, which I now prefer for new projects) automatically salts each password, which protects against rainbow-table attacks even if my database is ever leaked.
Session-Based Authentication
Once a password is verified, I need to keep the user logged in. The traditional approach is server-side sessions with a cookie:
const express = require('express');
const session = require('express-session');
const app = express();
app.use(session({
secret: process.env.SESSION_SECRET,
resave: false,
saveUninitialized: false,
cookie: {
httpOnly: true,
secure: true, // only sent over HTTPS
sameSite: 'strict', // CSRF mitigation
maxAge: 1000 * 60 * 60, // 1 hour
},
}));
app.post('/login', async (req, res) => {
const { username, password } = req.body;
const user = await findUserByUsername(username);
if (user && await verifyPassword(password, user.hashedPassword)) {
req.session.userId = user.id;
return res.json({ success: true });
}
res.status(401).json({ success: false });
});
The httpOnly flag is critical — it prevents JavaScript from reading the cookie, which blocks a huge class of XSS-based token theft.
Token-Based Authentication (JWT)
For APIs and single-page applications, I often reach for JSON Web Tokens instead of server sessions, especially when I need stateless, horizontally scalable authentication.
const jwt = require('jsonwebtoken');
function generateToken(user) {
return jwt.sign(
{ sub: user.id, username: user.username },
process.env.JWT_SECRET,
{ expiresIn: '15m' }
);
}
function verifyToken(token) {
try {
return jwt.verify(token, process.env.JWT_SECRET);
} catch (err) {
return null; // invalid or expired
}
}
On the client side, I typically do this:
async function login(username, password) {
const response = await fetch('/api/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password }),
});
const { token } = await response.json();
// Prefer storing in a httpOnly cookie set by the server
// rather than localStorage, whenever possible
return token;
}
I want to be direct about something here: storing JWTs in localStorage is common, but it’s also risky, because any successful XSS attack can read localStorage and steal the token. Whenever I have the choice, I set the JWT as an httpOnly cookie from the server instead, combined with short expiry and a refresh-token rotation strategy.
Refresh Tokens
Short-lived access tokens (15 minutes, in my example above) reduce the damage window if a token leaks, but they also mean the user would need to log in constantly without a refresh mechanism:
app.post('/refresh', async (req, res) => {
const refreshToken = req.cookies.refreshToken;
const stored = await findRefreshToken(refreshToken);
if (!stored || stored.revoked) {
return res.status(401).json({ error: 'Invalid refresh token' });
}
const user = await findUserById(stored.userId);
const newAccessToken = generateToken(user);
res.json({ accessToken: newAccessToken });
});
I always store refresh tokens server-side (in a database) so I can revoke them individually — logging a user out of one device shouldn’t be impossible just because JWTs are technically “stateless.”
OAuth and Third-Party Login
For “Sign in with Google/GitHub” flows, I don’t implement OAuth from scratch — the protocol has too many subtle security requirements. Instead, I use established libraries like Passport.js:
const passport = require('passport');
const GoogleStrategy = require('passport-google-oauth20').Strategy;
passport.use(new GoogleStrategy({
clientID: process.env.GOOGLE_CLIENT_ID,
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
callbackURL: '/auth/google/callback',
}, async (accessToken, refreshToken, profile, done) => {
const user = await findOrCreateUser(profile);
done(null, user);
}));
app.get('/auth/google', passport.authenticate('google', { scope: ['profile', 'email'] }));
app.get('/auth/google/callback',
passport.authenticate('google', { failureRedirect: '/login' }),
(req, res) => res.redirect('/dashboard')
);
Client-Side Route Protection
In a single-page app, I also protect routes on the frontend, purely for UX (real security always lives on the server):
function useAuthGuard(navigate) {
useEffect(() => {
fetch('/api/me', { credentials: 'include' })
.then((res) => {
if (!res.ok) navigate('/login');
});
}, []);
}
The Event Loop and Async Auth Checks
Authentication checks are almost always asynchronous — verifying a password hash, hitting a database, or validating a token can take time. I always make sure my middleware correctly awaits these operations rather than assuming synchronous completion:
app.use(async (req, res, next) => {
const token = req.cookies.accessToken;
const payload = verifyToken(token); // synchronous JWT verify
if (!payload) return res.status(401).end();
req.user = await findUserById(payload.sub); // async DB call
next();
});
A mistake I made early on was calling next() before an async lookup resolved, letting unauthenticated requests slip through. Always trace your awaits carefully in auth middleware.
Security Best Practices
- Always hash passwords with bcrypt or argon2 — never MD5 or SHA-1.
- Use
httpOnly,secure, andsameSitecookies wherever possible. - Rate-limit login endpoints to slow down brute-force attempts.
- Rotate refresh tokens and detect reuse (a classic sign of token theft).
- Never put sensitive data (like passwords) in a JWT payload — it’s only base64-encoded, not encrypted.
- Enforce HTTPS everywhere; cookies marked
securewon’t even be sent over plain HTTP.
Common Mistakes
| Mistake | Consequence | Fix |
|---|---|---|
| Storing JWT in localStorage | Vulnerable to XSS token theft | Use httpOnly cookies instead |
| No token expiry | Stolen tokens valid forever | Set short expiry + refresh tokens |
Comparing passwords with === | Timing attacks | Use bcrypt.compare / argon2.verify |
| Missing CSRF protection with cookies | Cross-site request forgery | Use sameSite cookies + CSRF tokens |
| Not revoking refresh tokens on logout | Stolen sessions persist | Store and invalidate tokens server-side |
FAQs
Should I build my own authentication system? For simple apps, yes, using proven libraries like bcrypt and jsonwebtoken. For anything with real security stakes, I’d strongly consider a managed provider (Auth0, Clerk, Firebase Auth) unless you have a dedicated security team.
Are JWTs better than sessions? Neither is universally “better.” Sessions are simpler to revoke and keep sensitive data server-side. JWTs scale better across distributed systems but require more careful handling to revoke.
Do I need HTTPS for authentication to be secure? Yes, unconditionally. Without HTTPS, credentials and tokens can be intercepted in transit.
Summary and Key Takeaways
Authentication is deceptively simple to start and genuinely hard to get fully right. The core principles I always keep in mind:
- Never store plaintext passwords; always hash with bcrypt or argon2.
- Prefer httpOnly cookies over localStorage for tokens where possible.
- Use short-lived access tokens with a revocable refresh mechanism.
- Rely on established libraries (Passport.js, jsonwebtoken) rather than reinventing cryptographic primitives.
- Treat every auth middleware function as security-critical code and review it accordingly.
References
- MDN — HTTP Cookies
- OWASP — Authentication Cheat Sheet
- Node.js Documentation — nodejs.org/en/docs
- jsonwebtoken — npm documentation
