boot

The Reality of Authentication: From Simple Hashes to OAuth2

8 min read
Venkat Nithin
AuthSecurityReact QueryNext.jsOAuth2

Initially, I thought authentication was incredibly easy. I used to wonder why anyone pays for software like Clerk or Auth0. I mean, how hard can it be? Take an email and a password, hash the password, store it in a database. Whenever a user logs in, hash the input, check if it matches, and grant access by creating a session. Simple, right?

But as I built more complex applications, I quickly realized what authentication actually entails. There is an entire iceberg beneath that simple email/password form.

Here is a topic-wise breakdown of what a production-ready authentication system actually requires, drawing from my experiences building custom auth from scratch as well as leveraging robust tools like NextAuth for OAuth2.


Table of Contents


01. Validations: You Can't Trust User Input

When a user enters their password and other details, you cannot just trust the input from the frontend. You have to validate it on the server, especially ensuring passwords are cryptographically strong. Using libraries like Zod makes this declarative and foolproof.

Here is an example of enforcing strict password policies before data ever touches the database:

import { z } from 'zod';

export const LoginSchema = z.object({
    email: z.string().email('Invalid email'),
    password: z.string()
    .min(8, 'Password must be at least 8 characters long')
    .refine((pwd) => /[A-Z]/.test(pwd), { message: "Need uppercase" })
    .refine((pwd) => /[a-z]/.test(pwd), { message: "Need lowercase" })
    .refine((pwd) => /\d/.test(pwd), { message: "Need number" })
    .refine((pwd) => /[!@#$%^&*]/.test(pwd), { message: "Need special char" }),
});

Interactive Zod Validation DemoPLAYGROUND

At least 8 characters
Contains uppercase letter
Contains lowercase letter
Contains a number
Contains special character

02. The Token Cycle and Cookies

If you're building traditional JWT auth, you don't just hand out a single token that lives forever. You need an Access Token (short-lived, say 15 minutes) and a Refresh Token (long-lived, say 7 days).

But where do you store them? LocalStorage is highly vulnerable to XSS (Cross-Site Scripting). The industry standard is to store them in HTTP-only Cookies.

const setAuthCookies = (res: Response, accessToken: string, refreshToken: string) => {
  return res
    .cookie("accessToken", accessToken, { httpOnly: true, secure: true, sameSite: "strict" })
    .cookie("refreshToken", refreshToken, { httpOnly: true, secure: true, sameSite: "strict" });
};
  • httpOnly: JavaScript cannot read the cookie. This kills XSS attacks dead.
  • secure: The cookie is only sent over HTTPS.
  • sameSite: Prevents the cookie from being sent in cross-site requests.

When the Access Token expires, the frontend shouldn't force the user to log in again. It should automatically use the Refresh Token in the background to get a new Access Token.

JWT Decoder SimulationPLAYGROUND

Raw JWT Token

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOiI1ZjhmYWU0Y2EyMTk0Iiwicm9sZSI6InBob3RvZ3JhcGhlciIsImlhdCI6MTcxNjM5ODQwMCwiZXhwIjoxNzE2NDA1NjAwfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c

03. CSRF Protection: Trusting No One

Because cookies are attached automatically to requests by the browser, they are vulnerable to CSRF (Cross-Site Request Forgery). If a user is logged into your app and visits a malicious site, that site could send a POST request to your /api/delete-account endpoint, and the browser would attach the auth cookies automatically!

To prevent this, you implement a double-submit cookie strategy. The backend verifies that the custom header matches the cookie:

export const csrfProtection = (req: Request, _res: Response, next: NextFunction) => {
  if (["GET", "HEAD", "OPTIONS"].includes(req.method)) return next();

  const csrfCookie = req.cookies?.["csrfToken"];
  const csrfHeader = req.headers["x-csrf-token"];

  if (!csrfCookie || !csrfHeader || csrfCookie !== csrfHeader) {
    throw new ApiError(403, "Invalid CSRF token");
  }
  next();
};

On the frontend, an Axios interceptor seamlessly attaches this header to every unsafe request, fetching it if missing:

apiClient.interceptors.request.use(async (config) => {
  if (["GET", "HEAD", "OPTIONS"].includes((config.method ?? "GET").toUpperCase())) {
    return config;
  }

  let csrfToken = getCookieValue("csrfToken");
  if (!csrfToken) {
    await apiClient.get("/auth/csrf");
    csrfToken = getCookieValue("csrfToken");
  }

  if (csrfToken) {
    config.headers.set("x-csrf-token", csrfToken);
  }
  return config;
});

04. Token Validation Middleware & Role Gates

Every protected API route goes through an Auth Middleware. It reads the cookie, verifies the JWT, and attaches the user to the request. But it doesn't stop there.

Once the user identity is confirmed, you implement Role Gates to restrict access based on permissions. For example, ensuring only users with an admin or photographer role can access certain studio routes:

export const requireRole = (allowedRoles: string[]) => {
  return async (req: Request, res: Response, next: NextFunction) => {
    if (!req.user || !allowedRoles.includes(req.user.role)) {
      return res.status(403).json({ error: "Access denied" });
    }
    next();
  };
};

05. Rate Limiting: Preventing Brute Force

Authentication endpoints are prime targets for attacks like credential stuffing. If an attacker tries to guess passwords by sending thousands of requests to your login route, your server could crash.

Using express-rate-limit, you can strictly throttle these attempts without punishing legitimate users by skipping successful requests:

export const authRateLimiter = rateLimit({
  windowMs: 15 * 60 * 1000, // 15 minutes
  limit: 10, // Max 10 attempts
  standardHeaders: true,
  skipSuccessfulRequests: true, // Don't penalize users who successfully log in
  handler: (req, res) => {
    res.status(429).json({ error: "Too many authentication attempts. Please try again later." });
  }
});

06. OAuth2 and Single Sign-On (SSO)

What if you don't want to manage passwords at all? Enter OAuth2. In applications where user friction must be kept to an absolute minimum (like a SaaS platform), "Login with Google" is a lifesaver.

This completely bypasses the need for your own password hashing and email verification. Using NextAuth.js, you can intercept the Google OAuth payload and automatically sync it with your own database to manage roles:

// lib/auth.ts (NextAuth Configuration)
export const authOptions: NextAuthOptions = {
  providers: [
    GoogleProvider({
      clientId: process.env.GOOGLE_CLIENT_ID ?? "",
      clientSecret: process.env.GOOGLE_CLIENT_SECRET ?? "",
    }),
  ],
  callbacks: {
    async signIn({ user }) {
      await connectionToDatabase();
      const dbUser = await User.findOne({ email: user.email });

      if (!dbUser) {
        // Automatically onboard new users via Google
        await User.create({
          name: user.name,
          email: user.email,
          image: user.image,
          googleId: user.id,
          role: "staff", // Default role
        });
      }
      return true;
    },
    async jwt({ token }) {
      // Attach database roles to the JWT so the frontend knows their permissions
      const dbUser = await User.findOne({ email: token.email });
      if (dbUser) {
        token.id = dbUser._id.toString();
        token.role = dbUser.role;
      }
      return token;
    },
  }
};

This delegates the heavy lifting of password security to Google, while you retain full control over your application's authorization and roles.

07. Frontend State: Eliminating the Login Flicker

Finally, once a user logs in successfully, you don't want to wait for the page to redirect and then fetch the user data (which causes a nasty loading spinner flicker).

Using tools like React Query (TanStack Query), you can manually inject the user data into the frontend cache the exact millisecond the login request succeeds:

// After successful login...
queryClient.setQueryData(["session"], data.user);
queryClient.invalidateQueries({ queryKey: ["session"] });

Conclusion

Authentication is so much more than just bcrypt.compare(). It’s an intricate dance of strict validations, token lifecycles, HTTP-only cookies, CSRF defenses, role gates, rate limits, OAuth2 handshakes, and clever frontend caching. Building these systems from scratch is incredibly challenging, but it teaches you exactly why robust authentication standards exist!

Enjoyed this article?

Subscribe to get notified when I publish new content.