Next, let’s delve into how to implement JWT-based authentication in a Next.js application using HttpOnly Cookies. Utilizing HttpOnly Cookies prevents client-side JavaScript from accessing the cookies, thereby enhancing security. This approach is particularly effective in defending against XSS (Cross-Site Scripting) attacks.
Follow the step-by-step guide below to set up HttpOnly Cookies and securely manage JWT tokens.
1. Install Required Packages
First, install the `cookie` package to handle cookies and also install the TypeScript type definitions.
npm install cookie
npm install --save-dev @types/cookie
- cookie: Used for parsing and setting cookies between the server and client.
- @types/cookie: Type definitions for TypeScript.
2. Modify JWT Utility
Update the existing `utils/jwt.ts` file to provide token creation and verification functionalities.
`utils/jwt.ts`
import jwt from "jsonwebtoken";
export interface JwtPayload {
userId: string;
email: string;
}
export const signToken = (payload: JwtPayload): string => {
return jwt.sign(payload, process.env.JWT_SECRET as string, { expiresIn: "1h" });
};
export const verifyToken = (token: string): JwtPayload | null => {
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET as string) as JwtPayload;
return decoded;
} catch (error) {
console.error("JWT verification error:", error);
return null;
}
};
Description:
- `signToken`: Creates a JWT with the given payload.
- `verifyToken`: Verifies the provided token and returns the payload if valid. Returns `null` if invalid.
3. Set HttpOnly Cookies in API Routes
Modify the Next.js API routes to set JWT tokens as HttpOnly cookies. This example modifies the login API route.
`app/api/auth/login/route.ts`
import { NextRequest, NextResponse } from "next/server";
import axios from "axios";
import { signToken } from "@/utils/jwt";
import mongooseConnect from "@/lib/mongoose";
import User from "@/models/User";
import bcrypt from "bcryptjs";
import { serialize } from "cookie";
export async function POST(request: NextRequest) {
try {
const { email, password } = await request.json();
if (!email || !password) {
return NextResponse.json({ error: "Email and password are required." }, { status: 400 });
}
await mongooseConnect();
const user = await User.findOne({ email });
if (!user) {
return NextResponse.json({ error: "Invalid email or password." }, { status: 400 });
}
const isMatch = await bcrypt.compare(password, user.password);
if (!isMatch) {
return NextResponse.json({ error: "Invalid email or password." }, { status: 400 });
}
const token = signToken({ userId: user._id, email: user.email });
// Set token as HttpOnly cookie
const response = NextResponse.json({ message: "Login successful" }, { status: 200 });
response.headers.set(
"Set-Cookie",
serialize("token", token, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "strict",
maxAge: 60 * 60, // 1 hour
path: "/",
})
);
return response;
} catch (error: any) {
console.error("Login Error:", error.response?.data?.message || error.message);
return NextResponse.json({ error: "An error occurred during login." }, { status: 500 });
}
}
Description:
- Login Verification: Validates the user’s entered email and password.
- JWT Creation: Generates a JWT with the verified user information.
- Set HttpOnly Cookie: Sets the generated JWT as an HttpOnly cookie via the `Set-Cookie` header.
- `httpOnly: true`: Ensures the cookie is inaccessible to client-side JavaScript.
- `secure: true`: Ensures the cookie is only sent over HTTPS.
- `sameSite: “strict”`: Prevents the cookie from being sent with cross-site requests.
- `maxAge`: Sets the cookie’s expiration time.
- `path: “/”`: Makes the cookie accessible across all paths.
4. Update Authentication Middleware
Modify the existing authentication middleware to read the JWT token from cookies instead of the Authorization header.
`middlewares/auth.ts`
import { NextRequest, NextResponse } from "next/server";
import { verifyToken, JwtPayload } from "@/utils/jwt";
import { parse } from "cookie";
export const authenticate = (request: NextRequest): JwtPayload | null => {
const cookie = request.headers.get("cookie");
if (!cookie) {
return null;
}
const cookies = parse(cookie);
const token = cookies.token;
if (!token) {
return null;
}
const decoded = verifyToken(token);
return decoded;
};
Description:
- Cookie Parsing: Extracts the JWT token from the `cookie` header.
- Token Verification: Verifies the extracted token and returns the payload if valid.
5. Handle Login and Logout on the Client Side
Since the client cannot directly access JWT tokens from cookies, adjust the method of managing authentication state.
5.1. Update Login Component
No need to store the token in `localStorage` after login. Instead, send the login request and update the authentication state accordingly.
`components/Login.tsx`
import { useState } from "react";
const Login = () => {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const handleLogin = async () => {
try {
const response = await fetch("/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password }),
});
const data = await response.json();
if (response.ok) {
alert("Login successful!");
// Update login state (e.g., redirect or state management)
} else {
alert(data.error);
}
} catch (error: any) {
console.error("Login Error:", error);
alert("An error occurred during login.");
}
};
return (
<div>
<h2>Login</h2>
<input
type="text"
placeholder="Email or Username"
value={email}
onChange={(e) => setEmail(e.target.value)}
/><br/>
<input
type="password"
placeholder="Password"
value={password}
onChange={(e) => setPassword(e.target.value)}
/><br/>
<button onClick={handleLogin}>Login</button>
</div>
);
};
export default Login;
5.2. Create Logout API Route
Create an API route that removes the cookie upon logout.
`app/api/auth/logout/route.ts`
import { NextRequest, NextResponse } from "next/server";
import { serialize } from "cookie";
export async function POST(request: NextRequest) {
const response = NextResponse.json({ message: "Logout successful" }, { status: 200 });
response.headers.set(
"Set-Cookie",
serialize("token", "", {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "strict",
expires: new Date(0), // Expire the cookie
path: "/",
})
);
return response;
}
Description:
- Remove Cookie: Expires the `token` cookie via the `Set-Cookie` header.
5.3. Create Logout Component
When the logout button is clicked, call the logout API to remove the cookie.
`components/Logout.tsx`
const Logout = () => {
const handleLogout = async () => {
try {
const response = await fetch("/api/auth/logout", {
method: "POST",
});
const data = await response.json();
if (response.ok) {
alert("You have been logged out.");
// Update logout state (e.g., redirect or state management)
} else {
alert(data.error);
}
} catch (error: any) {
console.error("Logout Error:", error);
alert("An error occurred during logout.");
}
};
return <button onClick={handleLogout}>Logout</button>;
};
export default Logout;
6. Considerations for CSRF Protection
When using HttpOnly cookies, it’s essential to guard against CSRF (Cross-Site Request Forgery) attacks. Implement SameSite cookie attributes or introduce CSRF tokens.
6.1. Utilize SameSite Attribute
The previously set `sameSite: “strict”` attribute prevents cookies from being sent with cross-site requests, thereby mitigating CSRF attacks. Additionally, implementing CSRF tokens can further enhance security.
6.2. Introduce CSRF Tokens
Use CSRF tokens to verify the origin of requests. For example, use the `csrf` package to generate and validate tokens.
- Reference: In Next.js, libraries like next-csrf can be utilized.
7. Configure Environment Variables
Set the secret key used for signing JWTs as an environment variable. Ensure this key is never exposed by storing it in a `.env.local` file and adding it to `.gitignore`.
`.env.local`
MONGODB_URI=mongodb://localhost:27017/mindcrash
JWT_SECRET=your_super_secret_jwt_key_here
Cautions:
- Protect Secret Key: `JWT_SECRET` should be a complex and unpredictable string.
- Load Environment Variables: Next.js automatically loads the `.env.local` file. Restart the server to apply changes.
8. Complete Code Examples
8.1. `models/User.ts`
import mongoose, { Schema, Document, model } from "mongoose";
export interface IUser extends Document {
email: string;
password: string;
}
const UserSchema: Schema<IUser> = new Schema({
email: { type: String, required: true, unique: true, lowercase: true },
password: { type: String, required: true },
});
export default mongoose.models.User || model<IUser>("User", UserSchema);
8.2. `app/api/auth/login/route.ts`
import { NextRequest, NextResponse } from "next/server";
import { signToken } from "@/utils/jwt";
import mongooseConnect from "@/lib/mongoose";
import User from "@/models/User";
import bcrypt from "bcryptjs";
import { serialize } from "cookie";
export async function POST(request: NextRequest) {
try {
const { email, password } = await request.json();
if (!email || !password) {
return NextResponse.json({ error: "Email and password are required." }, { status: 400 });
}
await mongooseConnect();
const user = await User.findOne({ email });
if (!user) {
return NextResponse.json({ error: "Invalid email or password." }, { status: 400 });
}
const isMatch = await bcrypt.compare(password, user.password);
if (!isMatch) {
return NextResponse.json({ error: "Invalid email or password." }, { status: 400 });
}
const token = signToken({ userId: user._id, email: user.email });
// Set token as HttpOnly cookie
const response = NextResponse.json({ message: "Login successful" }, { status: 200 });
response.headers.set(
"Set-Cookie",
serialize("token", token, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "strict",
maxAge: 60 * 60, // 1 hour
path: "/",
})
);
return response;
} catch (error: any) {
console.error("Login Error:", error.response?.data?.message || error.message);
return NextResponse.json({ error: "An error occurred during login." }, { status: 500 });
}
}
8.3. `app/api/auth/logout/route.ts`
import { NextRequest, NextResponse } from "next/server";
import { serialize } from "cookie";
export async function POST(request: NextRequest) {
const response = NextResponse.json({ message: "Logout successful" }, { status: 200 });
response.headers.set(
"Set-Cookie",
serialize("token", "", {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "strict",
expires: new Date(0), // Expire the cookie
path: "/",
})
);
return response;
}
8.4. `middlewares/auth.ts`
import { NextRequest, NextResponse } from "next/server";
import { verifyToken, JwtPayload } from "@/utils/jwt";
import { parse } from "cookie";
export const authenticate = (request: NextRequest): JwtPayload | null => {
const cookie = request.headers.get("cookie");
if (!cookie) {
return null;
}
const cookies = parse(cookie);
const token = cookies.token;
if (!token) {
return null;
}
const decoded = verifyToken(token);
return decoded;
};
8.5. `app/api/data/route.ts`
import { NextRequest, NextResponse } from "next/server";
import mongooseConnect from "@/lib/mongoose";
import Data from "@/models/annotation";
import { authenticate } from "@/middlewares/auth";
export async function GET(request: NextRequest) {
const user = authenticate(request);
if (!user) {
return NextResponse.json({ error: "Not authenticated." }, { status: 401 });
}
try {
await mongooseConnect();
const data = await Data.find({});
return NextResponse.json(data, { status: 200 });
} catch (error) {
console.error("GET Error:", error);
return NextResponse.json({ error: "An error occurred while fetching data." }, { status: 500 });
}
}
export async function POST(request: NextRequest) {
const user = authenticate(request);
if (!user) {
return NextResponse.json({ error: "Not authenticated." }, { status: 401 });
}
try {
const body = await request.json();
await mongooseConnect();
const newData = new Data(body);
await newData.save();
return NextResponse.json(newData, { status: 201 });
} catch (error) {
console.error("POST Error:", error);
return NextResponse.json({ error: "An error occurred while saving data." }, { status: 500 });
}
}
8.6. `components/Login.tsx`
import { useState } from "react";
const Login = () => {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const handleLogin = async () => {
try {
const response = await fetch("/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password }),
});
const data = await response.json();
if (response.ok) {
alert("Login successful!");
// Update login state (e.g., redirect or state management)
} else {
alert(data.error);
}
} catch (error: any) {
console.error("Login Error:", error);
alert("An error occurred during login.");
}
};
return (
<div>
<h2>Login</h2>
<input
type="text"
placeholder="Email or Username"
value={email}
onChange={(e) => setEmail(e.target.value)}
/><br/>
<input
type="password"
placeholder="Password"
value={password}
onChange={(e) => setPassword(e.target.value)}
/><br/>
<button onClick={handleLogin}>Login</button>
</div>
);
};
export default Login;
8.7. `components/Logout.tsx`
const Logout = () => {
const handleLogout = async () => {
try {
const response = await fetch("/api/auth/logout", {
method: "POST",
});
const data = await response.json();
if (response.ok) {
alert("You have been logged out.");
// Update logout state (e.g., redirect or state management)
} else {
alert(data.error);
}
} catch (error: any) {
console.error("Logout Error:", error);
alert("An error occurred during logout.");
}
};
return <button onClick={handleLogout}>Logout</button>;
};
export default Logout;
8.8. `components/FetchData.tsx`
import { useEffect, useState } from "react";
const FetchData = () => {
const [data, setData] = useState<any[]>([]);
const [error, setError] = useState<string | null>(null);
const fetchData = async () => {
try {
const response = await fetch("/api/data", {
method: "GET",
credentials: "include", // Include cookies in the request
});
const result = await response.json();
if (response.ok) {
setData(result);
} else {
setError(result.error);
}
} catch (error: any) {
console.error("Data Fetching Error:", error);
setError("An error occurred while fetching data.");
}
};
useEffect(() => {
fetchData();
}, []);
if (error) {
return <div>{error}</div>;
}
return (
<div>
<h2>Data List</h2>
<pre>{JSON.stringify(data, null, 2)}</pre>
</div>
);
};
export default FetchData;
Description:
- `credentials: “include”`: Automatically includes cookies in API requests from the client side.
9. Security Considerations
When managing JWTs with HttpOnly Cookies, keep the following security best practices in mind.
9.1. Use HTTPS
- Essential: Since HttpOnly cookies contain sensitive information, it’s crucial to use **HTTPS** to encrypt data transmission. Ensure HTTPS is enforced, especially in production environments.
9.2. Set SameSite Attribute
- SameSite=Strict: Prevents cookies from being sent with cross-site requests, defending against CSRF attacks.
- SameSite=Lax: Allows cookies to be sent with some cross-site requests.
- SameSite=None: Permits cookies to be sent with all cross-site requests. Ensure the `Secure` attribute is also set in this case.
Recommendation: Use `SameSite=Strict` or `SameSite=Lax` to protect against CSRF attacks.
9.3. Defend Against CSRF
HttpOnly cookies are automatically sent with requests, making them susceptible to CSRF attacks. To defend against this:
- SameSite Attribute: As described above, setting `SameSite=Strict` or `SameSite=Lax`.
- CSRF Tokens: Include CSRF tokens in requests to verify their origin. Libraries like `next-csrf` can be utilized for this purpose.
9.4. Set Cookie Expiration
Configure appropriate expiration times for JWT tokens to enhance security. Avoid setting excessively long expiration times, which can pose security risks, and excessively short ones, which may hinder user experience.
Recommendation: Initially set the expiration to around 1 hour and consider implementing Refresh Tokens to renew tokens as needed.
9.5. Manage Secret Keys
`JWT_SECRET` is highly sensitive and must be handled securely:
- Manage via Environment Variables: Store in a `.env.local` file and ensure it’s excluded from version control systems like Git.
- Use Complex Keys: Employ sufficiently long and random strings to prevent predictability.
9.6. Password Security
- Hash Passwords: Always hash passwords before storing them. The example uses `bcrypt`.
- Enforce Strong Password Policies: Encourage users to create strong, secure passwords.
9.7. Input Validation
Thoroughly validate all user inputs to prevent Injection Attacks.
9.8. Manage Error Messages
Avoid including sensitive information in error messages to prevent attackers from gaining insights into the system’s internal workings.
9.9. Implement Rate Limiting
Apply Rate Limiting to API endpoints to protect against DDoS attacks. Libraries like `next-rate-limit` can be used for this purpose.
9.10. Set Security Headers
Use middleware such as Helmet to set HTTP security headers, helping defend against various web attacks like XSS and Clickjacking.
Conclusion
We have explored a step-by-step approach to implementing JWT-based authentication in Next.js applications using HttpOnly Cookies. This method enhances security by preventing client-side JavaScript from accessing JWT tokens.
In summary:
- Configure JWT Utility: Provide functionalities for token creation and verification.
- Modify API Routes: Set tokens as HttpOnly cookies during login and remove them during logout.
- Update Authentication Middleware: Read tokens from cookies to perform authentication.
- Adjust Client-Side Logic: Manage authentication state via cookies and include them in API requests.
- Adhere to Security Best Practices: Implement HTTPS, set SameSite attributes, defend against CSRF, and securely manage secret keys.
By following these steps, you can successfully integrate secure authentication features into your Next.js applications, ensuring robust protection for your users.