Nodejs: Implementing a Basic Authentication Mechanism

Last updated: February 7th 2025

Introduction

Authentication is the digital gatekeeper of your application—a single weak link can compromise user data, erode trust, and expose your system to malicious actors. While modern authentication methods like OAuth2 and passwordless logins have gained traction, the classic email and password approach remains foundational. In this guide, we’ll implement a basic authentication system using Node.js, Prisma, and Express, complete with account verification, session management, and password recovery.

If you didn't setup you node server yet, follow this guied on how to setup Node.js Typescript, Express, Prisma.

Why Authentication Matters More Than Ever

  • Data Protection: A breach in authentication can expose sensitive user data, leading to legal repercussions and reputational damage.
  • User Trust: Robust authentication mechanisms signal to users that their accounts are safeguarded.
  • Regulatory Compliance: Standards like GDPR and CCPA mandate secure handling of user credentials and personal information.

Database Design with Prisma: Structuring for Security

A well-designed database schema is the backbone of secure authentication. Below is our Prisma schema, optimized for security and scalability:

generator client {
  provider = "prisma-client-js"
}

datasource db {
  // In production you may want to switch to PostgreSQL or MySQL;
  // for now, this uses SQLite based on your env variable.
  provider = "sqlite"
  url      = env("DATABASE_URL")
}

model User {
  id                Int       @id @default(autoincrement())
  email             String    @unique
  username          String    @unique
  hash              String
  verificationToken String?  
  verifiedAt        DateTime? 
  createdAt         DateTime  @default(now())
  updatedAt         DateTime  @updatedAt
  lastLogin         DateTime  @default(now())
  sessions          Session[]

  @@index([email, username])
}

model Session {
  id           Int       @id @default(autoincrement())
  sessionToken String    @unique
  userId       Int
  user         User      @relation(fields: [userId], references: [id], onDelete: Cascade)
  ipAddress    String?
  userAgent    String?
  createdAt    DateTime  @default(now())
  expiresAt    DateTime

  @@index([userId])
}

Key Design Considerations:

  1. Hashing Passwords: Never store plaintext passwords. The hash field uses bcrypt (with a work factor of 12) to securely store hashed passwords, salting each hash automatically to defeat rainbow table attacks and ensure uniqueness even for identical passwords.

    Hashing is a cryptographic process that converts input data (like a password) into a fixed-length string of characters using a mathematical algorithm.

    Unlike encryption, hashing is a one-way function—meaning the original data cannot be retrieved from the hash. This ensures security, as even minor changes to the input produce a completely different hash. In authentication systems, hashing (e.g., with bcrypt) protects sensitive data by storing only the hashed value, not the raw password, making it nearly impossible for attackers to reverse-engineer the original information, even if the database is compromised.

  1. Verification Workflow : The verificationToken and verifiedAt fields facilitate email verification, ensuring that users validate their email address ownership before receiving full access to the system. This helps minimize the risk of fake accounts. Additionally, some SaaS platforms require users to submit a professional email address to prevent abuse of free trials by individuals who might otherwise take advantage of the service without a legitimate need.

  2. Session Management: The Session model tracks active sessions, allowing for secure logout and session invalidation. Storing ipAddress and userAgent aids in detecting suspicious activity by flagging logins from unrecognized devices or locations for manual review or 2FA escalation.

  1. Indexing: Indexes on email and username improve query performance while enforcing uniqueness constraints, preventing duplicate registrations and accelerating authentication lookup operations during login attempts.

Key Authentication Controllers: A Deep Dive

1. Registering a New User: Building a Secure Pipeline

User registration is a multi-step process requiring careful validation and security checks.

Input Validation: The First Line of Defense

The Yup schema ensures:

  • Usernames are 5–50 characters long.
  • Passwords meet minimum complexity requirements (10+ characters).
  • Email addresses are valid and properly formatted.
import * as yup from "yup";

export const registerSchema = yup.object().shape({
  username: yup.string().required().min(5).max(50),
  password: yup.string().required().min(10).max(50),
  passwordConfirmation: yup
    .string()
    .oneOf([yup.ref("password")], "Passwords must match"),
  email: yup.string().email().required(),
});

Registration Controller: Behind the Scenes

  • Duplicate Checks: Separate queries for email and username prevent race conditions (.aka clean code).
  • UUID Tokens: Using uuidv4() for verification tokens avoids JWT overhead while ensuring uniqueness.
  • Hashing: Bcrypt’s hashSync with a cost factor of 12 balances security and performance.

Why Not JWT for Verification Tokens?
While JWTs are versatile, UUIDs paired with database checks provide simplicity and immediate revocation capabilities. For time-bound operations (e.g., password resets), JWTs with short expirations may be preferable.

// src/controllers/authController.ts
import { NextFunction, Request, Response } from "express";
import { hashSync } from "bcrypt";
import { v4 as uuidv4 } from "uuid";
import prisma from "../utils/database";
import { registerSchema } from "../schema/register.schema";
import { validateSchema } from "../utils/validate.yup";

async function registerUser(req: Request, res: Response, next: NextFunction) {
  try {
    // Validate input
    const input = validateSchema(registerSchema, req.body);
    if (!input.valid) {
      res.status(400).json({ errors: input.errors });
      return
    }

    // Check if username is already taken
    const usernameUsed = await prisma.user.findUnique({
      where: { username: input.data.username },
    });
    if (usernameUsed) {
      res.status(409).json({ message: "Username is already taken." });
      return
    }

    // Check if email is already registered
    const emailUsed = await prisma.user.findUnique({
      where: { email: input.data.email },
    });
    if (emailUsed) {
      res.status(409).json({ message: "Email is already registered." });
      return
    }

    // Hash password
    const hash = hashSync(input.data.password, 12);
    delete input.data["passwordConfirmation"];

    // Generate a verification token
    const verificationToken = uuidv4();

    // Create new user in database with the verification token
    const newUser = await prisma.user.create({
      data: {
        ...input.data,
        hash: hash,
        verificationToken,
      },
    });

    // TODO: Send a verification email containing the verification token

     res.status(201).json({
      message: "User registered successfully. Please verify your email.",
      user: { id: newUser.id, username: newUser.username, email: newUser.email },
    });
    return
  } catch (error) {
    console.error("Error registering user:", error);
    next(error);
  }
}

export { registerUser };

Production-Ready Enhancements:

  • Rate Limiting: Prevent abuse by limiting registration attempts per IP.
  • Email Sanitization: Normalize emails (e.g., lowercase john@example.com vs. John@Example.COM).
  • Transactional Emails: Use services like SendGrid or Resend for reliable delivery.

2. Account Verification: Ensuring Real Users

The verification flow reduces spam accounts and ensures valid email ownership.

Token Validation Best Practices:

  • Short Expiration: Set tokens to expire in 1–24 hours (handled via cron jobs or background workers).
  • One-Time Use: Invalidate tokens after successful verification.
  • Security Logging: Log verification attempts to detect abuse.

Code Note: The example uses a simple token lookup. For expiration, add a verificationTokenExpiresAt field to the User model and validate it during verification.

// src/controllers/authController.ts
import { verify as jwtVerify } from "jsonwebtoken";

async function verifyAccount(req: Request, res: Response, next: NextFunction) {
  try {
    const { token } = req.query;
    if (!token || typeof token !== "string") {
      res.status(400).json({ message: "Invalid verification token." });
      return;
    }

    // Find user by verification token
    const user = await prisma.user.findFirst({
      where: { verificationToken: token },
    });

    if (!user) {
      res.status(400).json({ message: "Invalid or expired verification token." });
      return
    }

    // Update user record: set verifiedAt and clear the verificationToken
    const verifiedUser = await prisma.user.update({
      where: { id: user.id },
      data: { verifiedAt: new Date(), verificationToken: null },
    });

    res.status(200).json({ message: "Account verified successfully.", userId: verifiedUser.id });
    return
  } catch (error) {
    console.error("Error verifying account:", error);
    next(error);
  }
}

export { verifyAccount };

3. Logging In: Balancing Security and Usability

The login process combines credential validation with session management.

Critical Security Measures:

  • Timing Attack Mitigation refers to defensive techniques used to prevent attackers from exploiting variations in the time a system takes to perform specific operations (e.g., password comparisons, cryptographic).

These attacks analyze timing differences to infer sensitive data, such as password characters or encryption keys. To mitigate them, systems use constant-time algorithms that ensure operations take the same duration regardless of input (e.g., comparing password hashes with a fixed-time function like bcrypt's built-in comparison).

Other strategies include avoiding data-dependent code paths, adding randomized delays, and using hardware-oblivious implementations to eliminate measurable timing leaks.

  • Session Token Storage: Store tokens in HTTP-only, Secure, SameSite cookies to mitigate XSS and CSRF risks.

  • Device Fingerprinting: The userAgent and ipAddress help identify suspicious logins.

Why Sessions Over Stateless JWT?
Server-side sessions enable instant revocation (e.g., during logout) and are less prone to token leakage compared to stateless JWTs, if someone stole the JWT there is no way to validated, you will have till it expires or blacklist it.

// src/controllers/authController.ts
import { compareSync } from "bcrypt";
import { v4 as uuidv4 } from "uuid";

async function loginUser(req: Request, res: Response, next: NextFunction) {
  try {
    const { email, password } = req.body;

    // This is an alternative way to, yup, but it is not recommended to use it.
    if (!email || !password) {
      return res.status(400).json({ message: "Email and password are required." });
    }

    const user = await prisma.user.findUnique({
      where: { email },
    });

    if (!user || !compareSync(password, user.hash)) {
      return res.status(401).json({ message: "Invalid email or password." });
    }

    // Optional: Prevent login if account is not verified
    if (!user.verifiedAt) {
      return res.status(403).json({ message: "Account not verified. Please check your email." });
    }

    // Generate a session token
    const sessionToken = uuidv4();
    const expiresAt = new Date();
    expiresAt.setDate(expiresAt.getDate() + 7); // session valid for 7 days

    await prisma.session.create({
      data: {
        sessionToken,
        userId: user.id,
        ipAddress: req.ip,
        userAgent: req.get("User-Agent") || "",
        expiresAt,
      },
    });

    // Optionally update user's last login timestamp
    await prisma.user.update({
      where: { id: user.id },
      data: { lastLogin: new Date() },
    });

    return res.status(200).json({
      message: "Login successful.",
      sessionToken,
    });
  } catch (error) {
    console.error("Error logging in user:", error);
    next(error);
  }
}

export { loginUser };

4. Logout: Invalidation Done Right

Immediate session deletion ensures compromised tokens can’t be reused. For enhanced security:

  • Global Logout: Add an endpoint to invalidate all sessions for a user.
  • Client-Side Cleanup: Instruct clients to delete stored tokens (though server-side invalidation is critical).
// src/controllers/authController.ts
async function logoutUser(req: Request, res: Response, next: NextFunction) {
  try {
    const token = req.headers.authorization?.split(" ")[1]; // Expecting "Bearer <token>"
    if (!token) {
      return res.status(400).json({ message: "No session token provided." });
    }

    await prisma.session.delete({
      where: { sessionToken: token },
    });

    return res.status(200).json({ message: "Logged out successfully." });
  } catch (error) {
    console.error("Error logging out user:", error);
    next(error);
  }
}

export { logoutUser };

Break: Let's learn about Instagram’s Password Reset Vulnerability

In 2019, a critical flaw in Instagram’s password reset mechanism exposed millions of accounts to takeover risks. Attackers discovered that password reset links generated by the platform used predictable, sequential tokens—essentially, a six-digit codes (only 1,000,000 possible combinations) .This design flaw enabled malicious actors to brute-force reset links by iterating through possible token values, hijacking accounts without needing access to a user’s email or phone number. The incident underscores how even minor oversights in authentication workflows can lead to catastrophic breaches.

Anatomy of the Flaw

  1. Predictable Tokens: Instagram’s reset tokens lacked cryptographic randomness, relying instead on a simple numeric sequence. Attackers could guess valid tokens by iterating through potential values.
  2. No Rate Limiting: The absence of rate limiting on token validation endpoints allowed unlimited brute-force attempts, making it trivial to discover valid tokens.
  3. Long Token Expiration: Reset links remained active for extended periods, widening the attack window.

Consequences

  • Account Hijacking: Attackers could reset passwords for any account, granting full access to private messages, financial data (for linked payment methods), and sensitive media.
  • Trust Erosion: Users questioned Meta’s ability to safeguard their data, damaging brand reputation.
  • Regulatory Scrutiny: The flaw potentially violated GDPR and CCPA requirements for secure data handling.

How Our Implementation Mitigates Similar Risks

  1. Cryptographically Secure Tokens
    Unlike Instagram’s sequential tokens, our system uses UUIDv4 for verification and JWT with HMAC signatures for password resets. UUIDs are 128-bit random values (e.g., 9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d), offering 3.4 × 10³⁸ possible combinations—rendering brute-force attacks computationally infeasible. JWTs, signed with a server-side secret, ensure tokens cannot be forged or guessed.

  2. Short-Lived Tokens
    Reset tokens expire after 1 hour (expiresIn: "1h"), minimizing the window for exploitation. Instagram’s multi-day validity period amplified risks.

    // Reset tokens expire in 1 hour
    const resetToken = jwtSign({ userId: user.id }, process.env.JWT_SECRET!, { 
      expiresIn: "1h" 
    });
    

Let's keep going:

5. Password Reset: Secure Recovery Workflow

The reset process exemplifies defense-in-depth:

  1. Obfuscated Responses: Avoid revealing whether an email is registered to prevent enumeration attacks.
  2. Short-Lived Tokens: JWT’s expiresIn: "1h" limits the attack window, and handle .
  3. Confirmation Checks: Require users to re-enter their new password to prevent typos.

Production Tip: Add a passwordChangedAt field and invalidate all existing sessions after password changes.

// src/controllers/authController.ts
import { sign as jwtSign } from "jsonwebtoken";

async function requestPasswordReset(req: Request, res: Response, next: NextFunction) {
  try {
    const { email } = req.body;
    if (!email) {
      return res.status(400).json({ message: "Email is required." });
    }

    const user = await prisma.user.findUnique({
      where: { email },
    });

    if (!user) {
      // Respond with a generic message for security purposes
      return res.status(200).json({ message: "If the email is registered, you will receive a password reset link." });
    }

    // Generate a token (using JWT in this example)
    const resetToken = jwtSign({ userId: user.id }, process.env.JWT_SECRET!, { expiresIn: "1h" });

    // TODO: Send the reset token via email using an email service (e.g., Nodemailer)

    return res.status(200).json({ message: "Password reset link sent if the email is registered." });
  } catch (error) {
    console.error("Error requesting password reset:", error);
    next(error);
  }
}

export { requestPasswordReset };

Rest password

// src/controllers/authController.ts
async function resetPassword(req: Request, res: Response, next: NextFunction) {
  try {
    const { token, newPassword, newPasswordConfirmation } = req.body;

    if (!token || !newPassword || !newPasswordConfirmation) {
      return res.status(400).json({ message: "All fields are required." });
    }

    if (newPassword !== newPasswordConfirmation) {
      return res.status(400).json({ message: "Passwords do not match." });
    }

    // Verify the token (this example uses JWT)
    let payload;
    try {
      payload = jwtSign.verify(token, process.env.JWT_SECRET!);
    } catch (e) {
      return res.status(400).json({ message: "Invalid or expired token." });
    }

    const userId = (payload as any).userId;
    const hash = hashSync(newPassword, 12);

    await prisma.user.update({
      where: { id: userId },
      data: { hash },
    });

    return res.status(200).json({ message: "Password reset successfully." });
  } catch (error) {
    console.error("Error resetting password:", error);
    next(error);
  }
}

export { resetPassword };

Binding Routes: Finalizing the Flow

useing the Router() function from express, group all realted enpoints in on router

// src/routes/auth.route.ts
import { loginUser, logoutUser, registerUser, requestPasswordReset, resetPassword, verifyAccount } from "../controllers/authController";
import express from "express";

const route = express.Router({ caseSensitive: true });

route.post("/register", registerUser);
route.get("/verify-account", verifyAccount);
route.post("/login", loginUser);
route.post("/logout", logoutUser);
route.post("/request-reset-password", requestPasswordReset);
route.post("/reset-password", resetPassword);

export default route;

Now append the router group to your Express app: to make all of theses endpoints under the /auth/* path

// src/app.ts
import express from "express";
import authRouter from "./routes/auth.route";

const app = express();
app.use(express.json());

// Mount authentication routes
app.use("/auth", authRouter); 

// ... other middleware and routes

const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
  console.log(`Server running on port ${PORT}`);
});

Next Steps

You’ve built a solid foundation, but authentication is an ongoing battle. Consider adding:

  1. Two-Factor Authentication (2FA): Implement TOTP or SMS-based 2FA.
  2. Security Headers: Use Helmet.js to set CSP, HSTS, and XSS protections.
  3. Monitoring: Log authentication attempts and set up alerts for unusual activity.
  4. Regular Audits: Periodically review dependencies for vulnerabilities (e.g., npm audit).

Conclusion: Security Is a Journey, Not a Destination

Authentication is a critical component that demands rigor and continuous improvement. By following this guide, you’ve implemented essential safeguards like hashing, session management, and verification workflows. However, staying ahead of threats requires vigilance—keep dependencies updated, monitor industry trends, and educate yourself on security best practices.

This article was written by Ahmad AdelAhmad is a freelance writer and also a backend developer.

chat box icon
Close
combined chatbox icon

Welcome to our Chatbox

Reach out to our Support Team or chat with our AI Assistant for quick and accurate answers.
webdockThe Webdock AI Assistant is good for...
webdockChatting with Support is good for...