This level introduces a more secure and user-friendly approach to JWT authentication by leveraging HTTP-only cookies to store both the access and refresh tokens. Unlike the basic token-based approach from Level 0 (where tokens are returned in JSON and stored client-side, typically in localStorage), this method mitigates several security vulnerabilities and improves developer ergonomics for session handling in modern applications.
In this level, we separate concerns between short-lived and long-lived tokens:
- The access token is a short-lived JWT that authorizes requests to protected routes.
- The refresh token is a long-lived uuid stored in a secure database, used solely to issue new access tokens once the original has expired.
Both tokens are stored in HTTP-only cookies, which makes them inaccessible to JavaScript (protecting against XSS attacks), and — when combined with SameSite=Lax and Secure=true — helps defend against CSRF.
This approach mimics a session-like experience for the client (especially for SPAs or SSR apps), while maintaining the stateless nature of JWTs on the server.
- Features
- Tech Stack
- How It Works
- Endpoints
- Cookie Configuration
- Example Request
- Security Improvements
- Security Notes
- Testing
- Installation
- Environment Variables
- Running the App
- License
- Users authenticate by providing their credentials(login and password) via
/auth/sign-up.
-
Secure Routes: Access and refresh (Only for automatic access token renewal) tokens required inside the cookie for protected routes, ensuring that only authenticated users can access sensitive data or perform critical actions.
-
Secure Token Validation Middleware: Every request to a protected route checks the
access_tokencookie. If the access token is expired, the server automatically generates a new one using the refresh token. During this process, the refresh token is rigorously validated against the database — if valid, it’s reissued, but if expired or invalid, it’s immediately revoked (deleted from the database) and both theaccess_tokenandrefresh_tokencookies are cleared from the client. This approach guarantees secure, short-lived access tokens while enforcing strict refresh token validation and automatic cleanup of compromised or stale sessions.
/auth/sign-outendpoint clears the cookies and removes the refresh token from the database to ensure full logout.
- Scheduled Task: CRON Job: We use a CRON job to schedule a task that periodically deletes all expired refresh tokens from the database. This ensures that only valid refresh tokens are stored in the database. The CRON job is set to run every 3 days, at 3:00 am. This frequency provides an optimal balance between ensuring security and minimizing performance impact on our database.
- Prisma ORM: Utilize Prisma's type-safe PostgreSQL interactions to simplify database queries and schema management.
- Database Schema: Leverage a pre-defined database schema for efficient data storage and retrieval.
- DTO-based Request/Response Schemas: Ensure data consistency and security with request/response schemas based on Data Transfer Objects (DTOs).
- Validation Middleware: Built-in validation middleware to catch and handle invalid input data.
- Built-in Middleware: Deployed with CORS, Helmet, and rate limiting for comprehensive security protection.
- Cross-Site Scripting (XSS) Protection: Helmet middleware ensures that user input is properly sanitized to prevent XSS attacks.
- Rate Limiting: Mitigate brute-force attacks by enforcing reasonable request limits.
- Tokens Security: Access & refresh tokens stored in
HTTP-only,secure,signed,sameSite:'lax'cookies.
- End-to-End Test Suite: Utilize Supertest for comprehensive testing of API endpoints, ensuring seamless integration with the application.
- Test Coverage: Robust test coverage ensures that critical functionality is thoroughly validated and reliable.
- Interactive Swagger UI: Access an interactive and user-friendly documentation interface at
/api, providing a clear understanding of available endpoints and parameters.
- Docker Support: Leverage Docker containers for efficient deployment, isolation, and scalability.
- Backend Framework: NestJS
- Programming Language: TypeScript
- Database: PostgreSQL
- ORM: Prisma
- Authentication: JWT, cookie-parser
- API Documentation: Swagger
- Rate Limiting: NestJS Throttler
- Task Scheduling: NestJS Schedule
- Password Hashing: Bcrypt
- Security Enhancements: Helmet
- Testing: Supertest, cookie-signature
The journey begins when a user attempts to register or log in through the application. They send their credentials (login and password) to one of two routes:
/auth/sign-up: For new users who want to create an account./auth/sign-in: For existing users who need to authenticate.
The server receives the user's request and verifies their credentials against the database. If everything checks out, we proceed with the next step.
Upon successful verification, the server generates a short-lived access token (JWT) for the user. This token contains essential information about the user, such as their ID.
The server generates the long-lived refresh token (UUID), which is stored in the database.
The client-side application receives access and refresh tokens inside secured HTTP-only cookies from the server. These tokens will be automatically sent to the server with each request.
When users attempt to access a protected route, their client-side application automatically sends the stored access and refresh tokens inside cookies with each request.
The backend server receives the request and verifies the validity of the access token. If the access token is valid, the request will successfully pass the security middleware and get to the controller. If the access token has expired or is undefined, the refresh token will generate a new access token. During this process, the refresh token is rigorously validated against the database. If valid, it’s reissued, but if expired or invalid, it’s immediately revoked (deleted from the database) and both the access_token and refresh_token cookies are cleared from the client.
/auth/sign-outendpoint clears the cookies and removes the refresh token from the database to ensure full logout.
The server will automatically start the CRON job to schedule a task that periodically(every 3 days, at 3:00 am) deletes all expired refresh tokens from the database.
| Method | Endpoint | Description | Required Body |
|---|---|---|---|
| POST | /auth/sign-up |
Register a new user and set auth tokens | login: string, password: string |
| POST | /auth/sign-in |
Login and set access & refresh token | login: string, password: string |
| POST | /auth/sign-out |
Clear auth tokens and invalidate session | None |
For a detailed overview of the available API endpoints, request/response structures, and data models, the Swagger documentation is available at /api. This documentation provides interactive API exploration and helps developers understand and integrate with the API efficiently.
- httpOnly:
true - secure:
true - sameSite:
lax - signed:
true - maxAge:
15 minutes for access token and 7 days for refresh token (should be set in milliseconds) - path:
/
POST /auth/sign-up
{
"login": "Pier228
"password": "password123"
}{
"message": "User successfully registered"
}Set-Cookie: access_token=s%...; Path=/; HttpOnly; Secure; Expires=...GMT
Set-Cookie: refresh_token=s%...; Path=/; HttpOnly; Secure; Expires=...GMT
This level is focused on fixing the critical security issues found in basic token-based authentication. Let’s break down the key improvements:
Level 0 typically stores JWTs in localStorage, which is vulnerable to XSS attacks. In Level 1:
- Tokens are stored in HTTP-only cookies, so they cannot be accessed by malicious scripts.
- This offers strong protection against client-side injection vulnerabilities.
- By using
SameSite=Lax(orStrict) andSecure=truecookies, we reduce the surface area for Cross-Site Request Forgery (CSRF) attacks. - When needed, additional CSRF tokens can be added — though in many modern apps,
SameSitealone provides sufficient protection.
- In Level 0, once a JWT is issued, there's no way to revoke or rotate it.
- In Level 1, we use a refresh token system, allowing:
- Short-lived access tokens (better control).
- The ability to revoke refresh tokens on logout or abnormal behavior.
- Scheduled cleanup of expired refresh tokens in the database.
- Access tokens can now be short-lived (e.g., 5–15 minutes), because the refresh token ensures session continuity.
- If an access token is compromised, it has a very limited time to be misused.
- Users can explicitly logout, which triggers:
- Deletion of the refresh token from the DB.
- Clearing of cookies on the client.
- This ensures that sessions are fully terminated and cannot be silently reused.
Together, these improvements make Level 1 significantly more secure and production-ready compared to the raw, stateless approach in Level 0.
This level represents a significant improvement over basic JWT implementations and is generally suitable for production environments, provided that the best practices are respected.
Level 1 introduces a secure, session-like authentication flow using HTTP-only cookies and refresh tokens, which balances security, scalability, and developer convenience.
However, its production-readiness depends on how it is configured and deployed.
- Single Page Applications (SPAs) needing a secure login flow
- Public-facing applications that don’t require high-sensitivity data protection
- Systems needing session-like UX without traditional server-side sessions
- You handle extremely sensitive data (e.g., healthcare, banking)
- Your application faces high risk of targeted attacks
- You require fine-grained access control or audit logging
🧠 TL;DR:
Yes — this level can be used in production, as long as you correctly configure HTTPS, cookies, rate limiting, and token storage. However, for higher-security apps, consider combining this with additional layers like email verification, refresh token rotation, and user anomaly detection in later levels.
To ensure the application is working correctly, comprehensive testing has been implemented to cover all aspects of the JWT authentication flow. The application utilizes Supertest for E2E testing, which allows making HTTP requests directly from test code and verifying expected responses. Here's a snapshot of the test results:
As you can see, all tests passed successfully. This gives confidence in the correctness of the JWT authentication implementation and ensures it works as expected in different scenarios. You can also run this test cases using npm run test:e2e command.
$ git clone https://github.com/Pier228/level-1-cookie-auth.git
$ cd level-1-cookie-auth
$ npm installThe Docker image for this project is available on Docker Hub.
To run this application, you need to configure several environment variables.
- Create a .env file in the root directory of the project.
- Add required environment variables:
PORT: The port on which the server will run. This field is optional. By default will run on 3000 port.DATABASE_URL: MongoDB connection URL used to connect to the database.SALT_ROUNDS: Number of rounds for hashing passwords (bcrypt).JWT_SECRET: Secret key for signing and verifying JWT tokens.CORS_ALLOWED_ORIGIN: The URL of the domain from which it is allowed to send requests to the server (CORS settings).COOKIE_SECRET: Secret key for signing and verifying cookies.
You can also refer to the .env.example file for a complete list of required environment variables.
After setting up the .env file, you can start the application using the following commands:
# Generate prisma client
$ npx prisma generate
# Build the application
$ npm run build
# Start in development mode
$ npm run start
# Start in watch mode
$ npm run start:dev
# Start in production mode
$ npm run start:prodThis project is licensed under the MIT License - see the LICENSE file for details.

