Authentication: JWT strategy

Authentication: JWT strategy

#1: Authentication: Session Strategies
#2: Authentication: Google OAuth2.0 iwth PKCE
#3: Authentication: JWT strategy

LinkIconBest Practices for JWT Implementation

  1. Secure Storage: Store JWTs in HTTP-only cookies to prevent access from JavaScript, reducing the risk of XSS attacks.
  2. Token Expiration: Set a reasonable expiration time on JWTs to limit the time window for potential misuse.
  3. Token Revocation: Have a mechanism to revoke or blacklist compromised tokens to enhance security.
  4. Use HTTPS: Ensure that all communications between the client and server use HTTPS to prevent eavesdropping and man-in-the-middle attacks.
  5. Don’t Store Sensitive Data: Avoid storing sensitive data in the JWT payload, as the payload is easily readable.

LinkIconRegister

First step of an auth system. Here you take the password and hash it (commonly with bcrypt library), then store it in a database.

const app = express()
 
app.post("/api/auth/register", (req, res) => {
	const { email, password, ...restUserInfo}  = req.body
	
	// TODO: Hash password using bcrypt
	// TODO: Save new user into the database with hashed password
	// Return success
	return res.status(200).json({message: "successfull"})
})

LinkIconLogin

In this part, we will skip the password validation (that involves using bcrypt to compare both hashed passwords from user input and database).

import { encrypt } from 'jsonwebtoken'
const app = express()
 
app.post("/api/auth/login", (req, res) => {
	const { email, password }  = req.body
	
	// TODO: Validate password and get user name
	
	// Create the session
	const user = { email, name, isAdmin: true }
	const expires = new Date(Data.now() + 1000 * 10) // expires in 10 sec
	const session = await encrypt({ user, expires, isAdmin })
	
	// Save the cookie session in the response
	return res.cookies('session', session, { expires, httpOnly: true })
})

Notice we are using a jose, a third party-library to encrypt the session.

import { SignJWT } from 'jose'
 
export async function encrypt(payload) {
	return await new SignJWT(payload)
		.setProtectedHeader({ alg: 'HS256' })
		.setIssueAt()
		.setExpirationTime('10 sec from now')
		.sign(secretKey)
}

Notice the argument passed called sign(secretKey) that we did not talk about.

This is important. Every JWT must need a unique signature. The signature prevents someone from creating or modifying a token on the client and pretending it's valid.

When a request comes in, the server verifies the signature using that same secret. If the token was changed, even by one character, the signature won't match and the JWT becomes invalid.

So if someone edits the cookie and changes isAdmin: false to isAdmin: true, it won't work. They'd also need to generate a valid signature, which they can't do without the secret.

Library like jose handle the signature creation and verification for you, but they require your own secret key to do it.

The secret must be a string.

You should store the secretKey in an environment variable where people don't have access to.

How to generate secret keys

Here are different ways you can generate a secret key:

  • npx auth secret
  • openssl rand -base64 32

LinkIconKeep Session Alive

Now that we create our JWT and a session in cookies. We need to keep our session alive on every request. For that, we can use middleware.

Middleware is code that runs between client and server. Intercept the incoming request and do some operation before calling the API endpoint.

In this case we'll update the session to expire 10 seconds later.

export async function updateSessionMiddleware(req, res) {
	const session = req.cookies['session']
	if (!session) return
	
	const parsed = await decrypt(session)
	parsed.expires = new Date(Date.now() + 1000 * 10)
	
	const resNext = res.next()
	resNext.cookies('session', await encrypt(parsed), { httpOnly: true, expires: parsed.expires })
	
	return resNext
}

WHy even use middleware

Without middleware, you'd repeat the same logic in every route. That's messy and fragile.

Notice that we have a decrypt function.

import { jwtVerify } from 'jose'
 
export async function decrypt(input) {
	const { payload } = await jwtVerify(input, key, {
		algorithms: ['HS256']
	})
	
	return payload
}

There is a better way to keep our session alive. Refresh token.

LinkIconRefresh token

An access token (JWT) should be short-lived. Like 10 seconds in our example. But the user will get logged out constantly. It's bad for user experience.

So we introduce a refresh token, a long-lived token whose only job is to generate new access tokens.

The flow:

  1. User logs in.
  2. Server returns:
    • Short-lived access token (JWT).
    • Long-lived refresh token (random string).
  3. Client stores both in HTTP-only cookies.
  4. When the access token expires, server verifies the refresh token and issues a new access token.
app.post("/api/auth/login", (req, res) => {
	const { email, password }  = req.body
	
	// TODO: Validate password and get user name
	
	// Create the session
	const user = { email, name, isAdmin: true }
	
	const session = await encrypt({ user, short_expiration, isAdmin })
	const refersh_token = await encrypt({ long_expiration })
 
	// Save the cookie session in the response
	res.cookies('session', session, { 
		httpOnly: true,
		expires: new Date(Date.now() + 1000 * 10) // expires in 10 sec
	})
	return res.cookies('refresh_token', refresh_token, {
		httpOnly: true,
		expires: new Date(Date.now() + 1000 * 60 * 60 * 24 * 7) // expires in 7 days
	})
})

Important design decision:

  • Access token → stateless, self-contained JWT.
  • Refresh token → stateful, must be stored in the database.

Why refresh token is stateful? Because you need control in case refresh token gets stolen. Must be able to revoke it manually.

But we can do better. We can programmatically revoke stolen refresh tokens. With Token rotation.

LinkIconToken rotation

Token rotation is how you prevent refresh token from living forever.

If someone steals a refresh token, they can keep generating new access tokens forever.

Token rotation solves this.

The new flow:

  1. Client sends refresh token A.
  2. Server verifies A.
  3. Server deletes A from DB.
  4. Server generates refresh token B.
  5. Server stores B.
  6. Server returns:
    • New access token
    • New refresh token B
export async function updateSessionMiddleware(req, res) {
	const refreshToken = req.cookies["refreshToken"]
	if (!refreshToken) return res.status(401).json({ message: "Unauthorized" })
 
	const storedToken = await db.refreshTokens.findUnique({
		where: { token: refreshToken }
	})
 
	if (!storedToken) {
		// Possible token reuse attack
		return res.status(403).json({ message: "Token reuse detected" })
	}
 
	// Invalidate old token
	await db.refreshTokens.delete({ where: { token: refreshToken } })
 
	// Create new refresh token
	const newRefreshToken = crypto.randomUUID()
 
	await db.refreshTokens.create({
		data: {
			token: newRefreshToken,
			userId: storedToken.userId
		}
	})
 
	// Create new Session token
	const accessToken = await encrypt({ user: { id: storedToken.userId } })
 
	res.cookie("refreshToken", newRefreshToken, {
		httpOnly: true,
		expires: new Date(Date.now() + 1000 * 60 * 60 * 24 * 7) // 7 days
	})
 
	return res.cookie("session", accessToken, {
		httpOnly: true,
		expires: new Date(Date.now() + 1000 * 60 * 15) // 15 min
	})
})

LinkIconSession

Now we need a function that allows us to get the current session from cookies. This function must run in the server-side since cookies is HttpOnly (read-only on server-side).

export async function getSession(req) {
	const session = req.cookies['session']
	if (!session) return null
	return await decrypt(session)
}

You can either call this function in your DAL (Data Access Layer) or create a new HTTP endpoint api/auth/session and call this function.

If you want to retrieve the session from client-side then I recommend to create a GET endpoint that the client can call.

LinkIconLogout

This step is simple as deleting the session from the cookies.

app.post("/api/auth/logout", (req, res) => {
	res.cookies('session', '', { expires: new Date(0)})
})