Authentication: JWT strategy

#1: Authentication: Session Strategies
#2: Authentication: Google OAuth2.0 iwth PKCE
#3: Authentication: JWT strategy
Best Practices for JWT Implementation
- Secure Storage: Store JWTs in HTTP-only cookies to prevent access from JavaScript, reducing the risk of XSS attacks.
- Token Expiration: Set a reasonable expiration time on JWTs to limit the time window for potential misuse.
- Token Revocation: Have a mechanism to revoke or blacklist compromised tokens to enhance security.
- Use HTTPS: Ensure that all communications between the client and server use HTTPS to prevent eavesdropping and man-in-the-middle attacks.
- Don’t Store Sensitive Data: Avoid storing sensitive data in the JWT payload, as the payload is easily readable.
Register
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"})
})Login
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 secretopenssl rand -base64 32
Keep 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.
Refresh 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:
- User logs in.
- Server returns:
- Short-lived access token (JWT).
- Long-lived refresh token (random string).
- Client stores both in HTTP-only cookies.
- 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.
Token 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:
- Client sends refresh token A.
- Server verifies A.
- Server deletes A from DB.
- Server generates refresh token B.
- Server stores B.
- 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
})
})Session
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.
Logout
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)})
})