DEV Community

Precious Afolabi
Precious Afolabi

Posted on

Week 18: JWT, Security, and Breaking My Own Auth System

JSON Web Tokens

JWT Structure
A JWT has three parts separated by dots: header, payload, and signature. The header holds the algorithm and token type. The payload holds the user data. The signature verifies that the token has not been tampered with.
Nothing in the payload is encrypted. It is base64 encoded, which means anyone can decode it. Never put sensitive information like passwords or card numbers in a JWT.

Signing and Verifying
jwt.sign takes a payload, a secret, and options like expiry. It returns a token string. jwt.verify takes the token and the same secret. If the token is valid and unexpired, it returns the decoded payload. If not, it throws either JsonWebTokenError or TokenExpiredError, and you handle each differently.

Token Expiry
Short-lived access tokens and long-lived refresh tokens work together. The access token expires quickly, usually in 15 minutes. The refresh token lives longer, usually 7 days, and is used to issue a new access token without asking the user to log in again.

Authorization and Role-Based Access Control

Authentication vs Authorization
Authentication answers "who are you?" Authorization answers "what are you allowed to do?" They are separate problems and need separate middleware.

Roles
Users get a role field on their document. Common roles are user, admin, and moderator. An enum restricts the value to only what is allowed. The authorization middleware checks the authenticated user's role against the roles a route permits.

Ownership Checks
Role checks are not always enough. A user with the role of "user" should be able to edit their own post, but not someone else's. Ownership middleware fetches the resource, compares the author field against the authenticated user's ID, and blocks access if they do not match. Admins bypass this check.

Security Best Practices

Helmet
Sets security-related HTTP headers automatically. One line of middleware covers a lot of surface area.

Rate Limiting
Restricts how many requests an IP can make in a time window. Auth routes get a stricter limit than general API routes. Limits brute force attacks on login.

Input Sanitization and Validation
express-mongo-sanitize strips characters that MongoDB uses as operators from request data. xss-clean removes script tags and HTML from inputs. express-validator validates and sanitizes fields before they reach the controller. Validation errors return 400 with specific messages about which field failed and why.

Body Size Limits
Setting a limit on the JSON body parser prevents large payload attacks. 10kb is a reasonable default for most APIs.

Environment Variables
Secrets go in .env and never get committed. The JWT secret should be long and random, generated with crypto.randomBytes(64).toString('hex') in a Node REPL. Different configs for development and production.

What I Built on Chronos
Instead of starting a new project, I added four features to Chronos, my study tracking API.

Forgot Password
Generates a raw token with crypto.randomBytes, hashes it with SHA256, and saves the hashed version to the user document along with a 10-minute expiry. Sends the raw token in a reset URL to the user's email. The raw token never touches the database.

Reset Password
Hashes the incoming token from the URL and queries the database for a user where the hashed token matches AND the expiry is still in the future. Both conditions are checked in one query. If either fails, the user is not found, and the request is rejected. Sets the new password and clears the reset fields.

Update Password
Requires the current password before allowing a change. Fetches the user with the password field included, runs bcrypt.compare, and only proceeds if the current password is correct.

Refresh Tokens
Generates a short-lived access token and a long-lived refresh token on login. Saves the refresh token to the user document. On refresh requests, it verifies the token signature and compares it against what is stored. On logout, it nulls the stored refresh token so it can never be used again.

The Bugs I Found Testing the Full Flow
Building something and testing it are different activities. I learned that again this week.
My logout handler was clearing a cookie called jwt. My login handler never set that cookie. It sent tokens in the response body. Logout was clearing something that did not exist, which meant the refresh token stayed valid in the database long after a user logged out.
My refresh route had auth middleware on it. That middleware validates the access token. A user hits the refresh endpoint precisely because their access token has expired. So the middleware was rejecting every legitimate refresh request before the handler ran. The feature existed and did not work.
Testing the full flow end-to-end caught both issues. Log in, use a protected route, let the access token expire, hit the refresh endpoint, use the new token, log out, try to refresh again. Each step either confirms or breaks something. Skipping this sequence would have shipped two silent failures.

Chronos: https://pchronos.vercel.app/

Question: When you find a bug that makes an entire feature non-functional, do you fix it immediately or finish building everything first and debug at the end?

Top comments (0)