Implementing Authentication in JavaScript Applications: A Practical, Complete Guide

Implementing Authentication in JavaScript Applications

Implementing Authentication in JavaScript Applications

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:

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

Common Mistakes

MistakeConsequenceFix
Storing JWT in localStorageVulnerable to XSS token theftUse httpOnly cookies instead
No token expiryStolen tokens valid foreverSet short expiry + refresh tokens
Comparing passwords with ===Timing attacksUse bcrypt.compare / argon2.verify
Missing CSRF protection with cookiesCross-site request forgeryUse sameSite cookies + CSRF tokens
Not revoking refresh tokens on logoutStolen sessions persistStore 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:

References

Exit mobile version