Authentication with JWT
Most web applications need to know who is making requests. Think of it like a building with a security desk: authentication is showing your ID badge to prove you are who you claim to be, while authorization is checking whether your badge grants access to the floor you are trying to reach. In this chapter, we’ll implement a complete authentication system using industry-standard practices.
Authentication vs. Authorization
Understanding JWT (JSON Web Tokens)
Traditional session-based authentication stores user data on the server. This works, but has challenges:
- Scalability: Sessions must be shared across multiple servers
- Mobile apps: Cookies don’t work well on native apps
- Microservices: Each service needs access to session store
JWT solves these problems with stateless authentication. Instead of storing session data on the server, the server issues a signed token that the client stores and sends with each request. Think of it like a concert wristband: the venue stamps it once at the entrance, and then every stage and bar inside just glances at your wrist instead of checking the guest list again.
How JWT Works
The critical insight: the server never stores the token. It only needs the secret key to verify the signature. This is what makes JWT “stateless” — the token itself carries all the information needed to authenticate the request.
JWT Structure
A JWT consists of three parts separated by dots:
- Header: Algorithm and token type
- Payload: User data (claims) like user ID, role, expiration
- Signature: Ensures the token hasn’t been tampered with
Never store sensitive information (passwords, credit cards) in the JWT payload. The payload is Base64-encoded, not encrypted—anyone can decode and read it.
Prerequisites
Install the necessary packages:
- jsonwebtoken: To sign and verify tokens.
- bcryptjs: To hash passwords securely.
1. User Registration (Hashing Passwords)
Never store passwords in plain text. If your database is compromised, every user’s password is exposed. Instead, use bcryptjs to hash them — a one-way transformation that turns "myPassword123" into an irreversible string like "$2a$10$N9qo8uLOi...". Even if an attacker steals the hashes, they cannot reverse them back into passwords. Bcrypt also adds a random salt to each hash, so two users with the same password get different hashes.
Why bcrypt over SHA-256 or MD5? General-purpose hash functions like SHA-256 are designed to be fast. That is a liability for password hashing — attackers can try billions of SHA-256 guesses per second. Bcrypt is intentionally slow and configurable, making brute-force attacks impractical. A cost factor of 10 takes roughly 50-100ms per hash, which is imperceptible to a user logging in but devastating to an attacker trying millions of passwords.
2. User Login (Comparing Passwords)
When a user logs in, compare the provided password with the hashed password in the database. You cannot “un-hash” the stored password to compare — instead, bcrypt hashes the incoming password with the same salt and checks if the result matches.
3. Generating a JWT
If the password is valid, generate a token and send it to the client. The token is a signed claim that says “this request is from user X, and I (the server) vouch for it.”
Store JWT_SECRET in your .env file. Use a long, random string (at least 256 bits / 32 characters). Generate one with: node -e "console.log(require('crypto').randomBytes(64).toString('hex'))". Never reuse secrets across environments — your development and production secrets should be completely different values.
4. Protecting Routes (Middleware)
Create middleware to verify the token on protected routes. This middleware acts as a gatekeeper — like the bouncer at a club who checks wristbands before letting anyone into the VIP area. Every request to a protected endpoint must pass through this function first.
middleware/auth.js
5. Using the Middleware
Apply the auth middleware to any route that requires a logged-in user. Express middleware executes in order, so auth runs before your route handler — if the token is invalid, the request never reaches your handler.
Summary
- bcryptjs: Use
hash() to encrypt passwords and compare() to validate
- JWT: Use
sign() to create tokens and verify() to validate
- Middleware: Create custom middleware to protect private routes
- Stateless: The server doesn’t store session data; the token contains necessary info
Access Tokens vs Refresh Tokens
A single long-lived token is a security risk: if stolen, an attacker has access for the entire token lifetime. A single short-lived token is a usability nightmare: users have to log in every 15 minutes. The solution is a dual-token system that gives you the best of both worlds — like a day pass (access token) and a membership card (refresh token). The day pass expires quickly but can be renewed by showing the membership card.
For production applications, implement this dual-token system:
Role-Based Access Control (RBAC)
RBAC restricts system access based on the roles assigned to users. Think of it like different colored badges in a hospital: doctors (admin) can access patient records and prescribe medication, nurses (moderator) can view records and update notes, and visitors (user) can only access the waiting area. The system checks your badge color, not your name, to determine what you can do.
OAuth 2.0 with Passport.js
OAuth lets users log in using their existing accounts (Google, GitHub, Facebook) instead of creating yet another username and password. From the user’s perspective, they click “Sign in with Google,” authorize your app, and land on your site — no new password to remember. From your perspective, you delegate the hard parts of authentication (password storage, account recovery, two-factor auth) to Google, and you receive a verified profile in return.
Passport.js is the de facto authentication middleware for Node.js. It uses a plugin architecture called “strategies” — each strategy handles a different authentication method (local password, Google OAuth, GitHub OAuth, etc.).
Password Reset Flow
Password reset is one of the most security-sensitive flows in any application. The pattern works like this: the user requests a reset, you generate a random token, email it as a link, and when they click the link, you verify the token and let them set a new password. The key security properties are: the token is single-use, time-limited, and the plaintext token is never stored in your database (only its hash).
Security Best Practices
- Never store JWTs in localStorage — any XSS vulnerability gives attackers full access to the token. Use httpOnly cookies instead, which JavaScript cannot read.
- Use httpOnly cookies for refresh tokens — they are automatically sent with requests and invisible to client-side scripts.
- Implement token blacklisting for logout — without it, a “logged out” user’s token remains valid until it expires.
- Use short expiration for access tokens — 15 minutes is a good default. Short-lived tokens limit the window of exploitation if one is stolen.
- Rotate refresh tokens on each use — when a refresh token is used, issue a new one and invalidate the old one. If an attacker steals a refresh token and uses it after the real user, the mismatch triggers revocation of all tokens for that user.
- Hash passwords with bcrypt (cost factor 10+) — never use MD5, SHA-1, or SHA-256 for passwords. They are too fast to resist brute-force attacks.
- Validate password strength on registration — enforce minimum length (8+ characters), and consider checking against known breached password lists using the Have I Been Pwned API.
Common pitfall: JWT token revocation. JWTs are stateless by design, which means the server has no built-in way to invalidate them before expiration. If a user changes their password or an account is compromised, you need a revocation strategy. Common approaches include: maintaining a short blocklist of revoked tokens in Redis (checked on each request), keeping token lifetimes very short (15 minutes), or including a “token version” in the user record and bumping it on security events.