JWT Authentication in Node.js: Building a Production-Ready Login System from Scratch A developer built a production-ready JWT authentication system in Node.js using Express, Prisma ORM, PostgreSQL, bcryptjs, jsonwebtoken, and cookie-parser. The system implements short-lived access tokens (15 minutes) and long-lived refresh tokens (7 days) stored in httpOnly cookies for security. The developer emphasized password hashing with bcrypt and the importance of token expiration to limit damage from token leaks. Authentication is one of the first major milestones every backend developer encounters. At first, it looks deceptively simple: User enters email User enters password Server checks credentials User gets logged in But underneath that simple flow is an entire security architecture involving password hashing, token signing, cookie security, database validation, and route protection. In this article, we'll walk through how JWT authentication actually works and how to build a complete authentication system using: Node.js Express Prisma ORM PostgreSQL bcryptjs jsonwebtoken cookie-parser By the end, you'll understand not just the code, but the reasoning behind every step. JWT stands for JSON Web Token. It is a compact string that allows a server to verify a user's identity without storing session data on the server. A JWT looks something like this: xxxxx.yyyyy.zzzzz The token consists of three sections separated by dots. The header contains metadata. json { "alg": "HS256", "typ": "JWT" } This tells us: The payload contains claims. json { "userId": 1, "email": "test@example.com", "iat": 123456789, "exp": 123457789 } Typical claims include: User ID Email Roles Permissions Expiration dates Important: The payload is NOT encrypted. Anyone can decode it. Never store passwords or sensitive information inside a JWT payload. This is where security comes from. The server combines: text Header + Payload + Secret Key Then generates a cryptographic signature. If someone modifies the payload: json { "userId": 999999, "role": "admin" } the signature becomes invalid. The server immediately detects the tampering. This is what makes JWT trustworthy. Let's walk through a complete login lifecycle. User submits: json { "email": "user@example.com", "password": "password123" } Server: Database stores: Email: user@example.com Password: $2a$10$L6... Never: Password: password123 Passwords should never be stored directly. Imagine your database leaks. If passwords are plain text: text password123 qwerty admin123 Attackers instantly gain access. Instead we hash passwords. Using bcrypt: js const hash = await bcrypt.hash password, 10 ; Output: $2b$10$A2K3... The original password cannot be recovered. When a user logs in: json { "email": "user@example.com", "password": "password123" } The server: Finds the user. js js const user = await prisma.user.findUnique { where: { email, }, } ; Compares passwords. js const validPassword = await bcrypt.compare password, user.password ; bcrypt hashes the submitted password and compares it to the stored hash. No decryption happens. After successful login: js js const accessToken = jwt.sign { id: user.id, email: user.email, }, process.env.JWT SECRET, { expiresIn: "15m", } ; The token now becomes proof of authentication. Many beginners ask: "Why not make the token last forever?" Because stolen tokens happen. A leaked token with: text expiresIn: "365d" gives attackers a year of access. A token with: text expiresIn: "15m" limits damage significantly. This introduces a problem: Users would constantly need to log in again. That's where refresh tokens come in. A refresh token is a long-lived token. Example: js js const refreshToken = jwt.sign payload, process.env.JWT REFRESH SECRET, { expiresIn: "7d", } ; Purpose: Access token expires quickly Refresh token issues new access tokens Users stay logged in. Security remains strong. Instead of sending refresh tokens in JSON responses: js res.cookie "refreshToken", refreshToken, { httpOnly: true, sameSite: "strict", maxAge: 7 24 60 60 1000, } ; Benefits: JavaScript cannot access the cookie. Protection against XSS attacks. Prevents many CSRF attacks. Automatically expires. A simple User model: prisma model User { id Int @id @default autoincrement email String @unique password String createdAt DateTime @default now } Generate client: npx prisma generate Push schema: npx prisma db push Or create migrations: npx prisma migrate dev A typical flow: js 1. Find user 2. Verify password 3. Generate access token 4. Generate refresh token 5. Set cookie 6. Return response The controller becomes the central authentication orchestrator. Authentication isn't useful until routes are protected. Example: http GET /profile Only authenticated users should access it. This is where middleware shines. Every request arrives with: http Authorization: Bearer eyJhbGc... Middleware: js js const authMiddleware = req, res, next = { const authHeader = req.headers.authorization; if authHeader { return res.status 401 .json { message: "Unauthorized", } ; } const token = authHeader.split " " 1 ; const decoded = jwt.verify token, process.env.JWT SECRET ; req.user = decoded; next ; }; The server checks: If any check fails: js jwt.verify throws an error. Request gets rejected. Because middleware attached the payload: js req.user controllers can access: req.user.id req.user.email without querying the database again. This is one of JWT's biggest advantages. Traditional sessions: Client → Session ID Server → Session Store Server must remember every session. JWT: Client → Token Server → Verify Token No session storage required. Everything needed exists inside the token. This is called stateless authentication. Never. JWT payloads are readable. Always use: expiresIn Wrong: jwt.decode token Decode does NOT verify. Use: jwt.verify token for authentication. Never send: user.password back to clients. Even hashed passwords should remain private. A production-ready setup usually looks like: POST /auth/register POST /auth/login POST /auth/logout POST /auth/refresh GET /auth/me Register: Create user. Login: Issue tokens. Refresh: Issue new access token. Logout: Remove refresh token. Me: Return current user. This architecture powers countless SaaS applications today. JWT authentication seems complicated at first because multiple concepts are happening simultaneously: Password hashing Database validation Token generation Cookie security Middleware protection Route authorization But once you understand the flow, everything starts to click. The key realization is this: Authentication is not about logging users in. Authentication is about proving identity securely on every request. JWT, bcrypt, Prisma, and Express work together to make that possible. Master these fundamentals and you'll have the foundation needed to build secure APIs, SaaS products, client portals, and production-grade backend systems. As usual be write code as art