Want to build “user auth” with tokens from scratch in NextJS environment without using the NextAuth.js package, (separate post on this).
I have implemented this with an express server persisting the token on the front-end in local storage.
The “jsonwebtoken” package gets about 9m downloads a week (as of 4/22).
$ npm install jsonwebtoken
Something like this found in LearnWebCode’s “UserController” file.
exports.apiMustBeLoggedIn = function (req, res, next) {
try {
req.apiUser = jwt.verify(req.body.token, process.env.JWTSECRET)
next()
} catch (e) {
res.status(500).send("Sorry, you must provide a valid token.")
}
}
Notice this is a “middleware” function for Express.
Okay so a few points here. First, the payload sent in the token can be decoded so don’t send anything sensitive information like a user’s password. You can send a username, maybe the email, the user’s id and sometimes you will see an “isAdmin” boolean.
Second, I mentioned two broad “user-auth” strategies of either Sessions or Tokens. Sessions has to protect against CSRF attacks but this is not a thing with tokens. But tokens have to protect against cross-site scripting (XSS) attacks. The “http only” option allows you to prevent the cookie from being accessible from JS.
But hold the door. Sessions versus Tokens is not the end of the choices as the token still need to be persisted on the front-end. You can use cookies or local storage.
This confused me a bit for a while now but Bruno finally brought some clarity for me. I assimilate “user-auth” strategies as either Sessions(cookies) or Tokens.
Note: The sessions strategy used in the FSFS course leveraged “express-session” package here which still has over 1m downloads a week (as of 4/22) so someone is still using it. This package stores session data in memory (the dev will store in db using a package like connect-mongo here which only has 100k downloads a week?) and sets a cookie.
But as mentioned, that’s not the end if the story as even if you choose tokens you need to persist the tokens, using either local storage or cookies. But when I hear cookies my brain goes back to sessions and that was the source of my initial confusion.
Storing JWT with Cookies: Cookie sent automatically. Can set the cookie to http only. Don’t need to worry about XSS but do need to prevent CSRF attacks (NextAuth.js can help with this).
Storing JWT with Local Storage: Need to send the token on each request. Don’t need to worry about CSRF but do need to prevent XSS attacks.
So which one is better?
RFTROU used local storage.
The “Computer Science Dev” author said, “we are going to be doing Next JS authentication using jwt and we are going to use cookies, instead of storing the token inside of local storage, because we are professionals.” You don’t use local storage because anyone that executes a script on your browser can basically steal your cookie.
Dev.to article here and here on why cookies is the choice.
Middleware functions are different in Next JS compared to Express, especially since Next12. See the separate post here. A quick mention, “A redirect in Middleware cannot be Relative URLs, more info from Next here, so the code referenced below needs some changes on redirecting.
From the dev.to article:
Access tokens are usually short-lived JWT Tokens, signed by your server, and are included in every HTTP request to your server to authorize the request.
Refresh tokens are usually long-lived opaque strings stored in your database and are used to get a new access token when it expires.
Dev tools are tab specific so check out for the sites you frequent. I was shocked by how much session data is stored from certain sites, (looking at you Udemy.com). Also interesting to see who uses tokens and who uses sessions.
This is the ideal approach if you don’t want to go with NexAuth.js as it persists the JWT in a cookie and uses Next12 middleware functions to protect routes. In addition to the “jsonwebtoken” package, he uses the “cookie” library here which has over 31m weekly downloads (as of 4/22). The only hesitation here is we still need to implement our own csrf protection.
The server-side code for logging a user in:
// pages/api/auth/login.js
import {sign} from "jwt"
import {serialize} from "cookie"
const secret = process.env.SECRET
export default async function(req, res) {
const {username, password} = req.body
// check in db
// if a user with this username
// and password exist
if(username === "Admin" && password === "Admin") {
const token = sign({
exp: Math.floor(Date.now() / 1000) + 60 * 60 * 24 * 30,
username: username
}, secret)
const serialized = serialize("OursiteJWT, token, {
httpOnly: true,
secure: process.env.NODE_ENV !== "development",
sameSite: "strict",
maxAge: 60 * 60 * 24 * 30,
path: "/"
}")
res.setHeader("Set-Cookie", serialised)
res.status(200).json({message: "success"})
} else {
res.status(401).json({message: "invalid creds"})
}
}
Note: The tutorial did not have a registration/login workflow just a login workflow but the concept is the same minus the storing of a validated user in the database.
Also Note: Not sure why the expiration of the token is set inside the payload instead of as a third argument to the “sign” method. The important thing is that the token and cookie expires at the same time.
See mdn on on “Set-Cookie” here.
And a quick example to read token to make sure it is there.
//pages/api/user.js
export default async function (req, res) {
const {cookies} = req
const jwt = cookies.OursiteJWT
console.log(jwt)
res.json({message: "success"})
}
And when hitting endpoint above from home page:
//pages/index.js
const handleSubmit = async (e) => {
e.preventDefault()
const credentials = {username, password}
const user = await axios.post("/api/auth/login", credentials)
console.log(user)
}
//pages/index.js
const handleGetUser = async (e) => {
e.preventDefault()
const user = await axios.post("/api/user")
console.log(user)
}
Now that we know it works we can use code to make sure it exists:
//pages/api/user
if(!jwt) {
res.json({message: "invalid token"})
}
res.json({data: "top secret data"})
And logging out:
//pages/api/auth/logout
// You still set a cookie just once that expires immediately
export default async handler(req, res) {
const {cookies} = req
const jwt = cookies.ourSiteJWT
if(!jwt) {
res.json({message: "Not logged in"})
} else {
const serialised = serialize("OurSiteJWT", null, {
httpOnly: true,
secure: process.env.NODE_ENV !== "development",
sameSite: "strict",
maxAge: -1,
path: "/"
})
res.setHeader("Set-Cookie", serialised)
res.status(200).json({message: "Successfully logged out."})
}
}
Note: it is the setting of the cookie with a maxAge of -1 that clears out the token.
Also Note: When logging out we send null in place of the token.
And logging in from the front-end
//pages/login
import {useRouter} from "next/router"
//inside of main component
const router = useRouter
const handleSubmit = async (e) => {
e.prevenDefault()
const credentials = {username, password}
const user = await axios.post("/api/auth/login", credentials)
if (user.status === 200) {
router.push("/dashboard/user")
}
}
And on the “landing” page “//pages/dashboard/user”
export default function User() {
return <h1>Sensitive Data</h1>
}
But at this point we are not really guarding the page so we need a Next JS middleware function. He is using the Next12 process and creates the “_middleware.js” file in the pages directory.
import {NextResponse} from "next/server"
import {verify} from "jsonwebtoken"
const secret = process.env.SECRET
export default function middleware(req) {
const {cookies} = req
const jwt = cookies.OursiteJWT
const url = req.url
if(url.includes("/dashboard")) {
if(jwt === undefined) {
return NextResponse.redirect("/login")
}
try {
verify(jwt, secret)
return NextResponse.next()
} catch(e) {
return NextResponse.redirect("/login")
}
}
return NextResponse.next()
}
Note: If you want to access the payload on the token you don’t call the verify method directly but store in a variable.
You can have multiple conditional checks in you middleware file so for example, if you don’t want users to be able to see the login screen if they are logged in:
if (url.includes("/login")) {
// similar logic to above...
// if token
// if token can be verified
// push to the dashboard
}
Note: You can move these conditional checks into a function to keep this file lean.
My version:
import { sign } from "jsonwebtoken"
import { serialize } from "cookie"
import type { NextApiRequest, NextApiResponse } from "next"
import User from "../../models/User"
async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method !== "POST") {
res.status(500).json({ message: "There was an error." })
return
}
if (req.method === "POST") {
let user = new User(req.body)
try {
const response: any = await user.register()
//console.log(response)
if (response === "success") {
// define and sign token
const token = sign(
{
_id: response._id.toString(),
username: response.username
},
process.env.JWTSECRET!,
{ expiresIn: "30d" }
)
// create serialized cookie
const serialized = serialize("SimpleCarCostToken", token, {
httpOnly: true,
secure: process.env.NODE_ENV !== "development",
sameSite: "strict",
maxAge: 60 * 60 * 24 * 30,
path: "/"
})
res.setHeader("Set-Cookie", serialized)
res.status(200).json({
data: {
username: response.username
}
})
}
} catch (e) {
res.status(422).json(e)
}
}
}
export default handler
1.) Codedamn here doesn’t really dig into too much
2.) Scalable Scripts here: first introduction to the “credentials: include” sent in the fetch request (mdn).
3.) LearnWebCode repo here
4.) Computer Science Dev here and here: Gets straight to the point and uses Next JS middleware functions.
5.) My guy Bruno here
6.) jwt.io to decode the base64 encoding
7.) dev.to article
8.) Another dev.to here called “Please sop using local storage”