Implement JWT Refresh Token Authentication with Elysia JS and Prisma: A Step-by-Step Guide
Harsh Mangalam
Posted on June 13, 2024
In this comprehensive guide, we'll walk you through the process of integrating JWT refresh token authentication into your application using Elysia JS and Prisma.
Authentication vs Authorization
Authentication is the process of verifying the identity of a user or system attempting to access a resource or service.
Authorization is the process of determining what actions or resources a user is permitted to access within a system or application after they have been successfully authenticated.
JWT
JSON Web Token (JWT) authentication is a stateless, token-based authentication mechanism used to securely transmit information between parties as a JSON object
Header: Contains metadata about the token, such as the type of token (JWT) and the signing algorithm (e.g., HMAC SHA256 or RSA).
Payload: Contains the claims, which are statements about an entity (typically, the user) and additional data. Claims can be of three types: registered, public, and private.
Signature: Ensures that the token hasn't been altered. It's created by taking the encoded header, the encoded payload, a secret, the algorithm specified in the header, and signing them.
Tech Stack
Bun - Bun is a javascript runtime just like Nodejs and Deno but with better performance and developer experience.
Elysia - Elysia is a web framework built on top of Bun just like Express is a web framework built on top of Nodejs.
Prisma - Prisma is an ORM and Database Toolkit provide smoother way to connect SQL and NoSQL database. Prisma provide easy to use API to interact with db.
PostgreSQL - PostgreSQL is the World's Most Advanced Open Source Relational Database.
Typescript - A javascript with type safety features.
Setup new elysia project
Step 1
Make sure Bun is already installed in your system. You can install bun using curl
curl https://bun.sh/install | bash
Step 2
Create new elysia project using bun. elysia-prisma-jwt-auth
is name of our project
bun create elysia elysia-prisma-jwt-auth
Step 3
Go to the project directory
cd elysia-prisma-jwt-auth
Step 4
Now you can open the project in vscode
code .
Step 5
Start the elysia server
bun dev
You can also follow Elsysia Quick start guide to setup project or if you want custom setup
https://elysiajs.com/quick-start.html
In the next process we will define our required routes
- POST
/api/auth/sign-up
- Create new account - POST
/api/auth/sign-in
- Sign in to existing account - GET
/api/auth/me
- Fetch current user - POST
/api/auth/logout
- Logout current user - POST
/api/auth/refresh
- Create new pair of access & refresh token from existing refresh token
Create new file route.ts
to keep all routes related code here
src/route.ts
import { Elysia } from "elysia";
export const authRoutes = new Elysia({ prefix: "/auth" })
.post(
"/sign-in",
async (c) => {
return {
message: "Sig-in successfully",
};
},
)
.post(
"/sign-up",
async (c) => {
return {
message: "Account created successfully",
};
},
)
.post(
"/refresh",
async (c) => {
return {
message: "Access token generated successfully",
};
}
)
.post("/logout", async (c) => {
return {
message: "Logout successfully",
};
})
.get("/me", (c) => {
return {
message: "Fetch current user",
};
});
Elysia is using method chaining to synchronize type safety for later use. Without method chaining, Elysia can't ensure your type integrity.
src/index.ts
import { Elysia } from "elysia";
import { authRoutes } from "./route";
const app = new Elysia({ prefix: "/api" }).use(authRoutes).listen(3000);
console.log(
`🦊 Elysia is running at ${app.server?.hostname}:${app.server?.port}`
);
Now import the auth routes and pass in use()
method. We have added the prefix /api
so that all the routes will start with /api
like sign-in will be now /api/auth/sign-in
.
Setup Prisma
Step 1
Install prisma cli as dev dependencies. dev dependencies are only required in local development it does not included in production build and runtime
bun add -d prisma
Step 2
Initialize prisma project
bunx prisma init
Step 3
Add prisma schema that will map to database table. Here we are going to add User
schema to store user information.
enum UserRole {
User
Admin
}
model User {
id String @id @default(uuid())
name String @db.VarChar(60)
email String @unique
password String
location Json?
isAdult Boolean @default(false)
isOnline Boolean? @default(false)
role UserRole? @default(User)
refreshToken String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
createdAt
field will be added when new user will be added. updatedAt
field will be initially same as createdAt
but will change once you will be update any field of this table. For id
here we are using uuid
this will generate unique id as a string.
We have also created an enum for user role their value can be User
or Admin
and will help to implement Authorization and role based authentication.
Step 4
Update .env
file created by prisma init command and add DATABASE_URL
value. We are using PostgrSQL hence the URI will be in the form of postgresql://username:password@host:port/db?schema=public
DATABASE_URL="postgresql://harshmangalam:123456@localhost:5432/meetup?schema=public"
You can omit ?schema=public
by default in postgres it is public
schema.
Step 5
Sync up the prisma schema with the postgresql database
bunx prisma db push
This command should not be used in production in production always run migration command instead of push command.
For better developer expericence you can put this command in package.json
scripts
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"dev": "bun run --watch src/index.ts",
"prisma:push": "bunx prisma db push"
},
So that later you can use this short command instead of long prisma command.
bun prisma:push
Step 6
Install @prisma/client
to make interaction with prisma server. usually this step is not required because during Step 5
its automatically get installed
bun i @prisma/client
Step 7
generate prisma schema types for autocomplete. This step is also not required usually because during Step 5
it automatically get generated and added types to node_modules
Step 8
Create new instance of prisma client so that we can reuse that instance to make interact with db.
lib/prisma.ts
import { PrismaClient } from "@prisma/client";
export const prisma = new PrismaClient();
Now our db setup is completed and ready to use the prisma instance in our route handlers.
Implement Sign-up
src/route.ts
import { loginBodySchema, signupBodySchema } from "./schema";
import { prisma } from "./lib/prisma";
import { reverseGeocodingAPI } from "./lib/geoapify";
import { jwt } from "@elysiajs/jwt";
import {
ACCESS_TOKEN_EXP,
JWT_NAME,
REFRESH_TOKEN_EXP,
} from "./config/constant";
import { getExpTimestamp } from "./lib/util";
.post(
"/sign-up",
async ({ body }) => {
// hash password
const password = await Bun.password.hash(body.password, {
algorithm: "bcrypt",
cost: 10,
});
// fetch user location from lat & lon
let location: any;
if (body.location) {
const [lat, lon] = body.location;
location = await reverseGeocodingAPI(lat, lon);
}
const user = await prisma.user.create({
data: {
...body,
password,
location,
},
});
return {
message: "Account created successfully",
data: {
user,
},
};
},
{
body: signupBodySchema,
error({ code, set, body }) {
// handle duplicate email error throw by prisma
// P2002 duplicate field erro code
if ((code as unknown) === "P2002") {
set.status = "Conflict";
return {
name: "Error",
message: `The email address provided ${body.email} already exists`,
};
}
},
}
)
Client will make an api call to /api/auth/sign-up
with the json body
{
"name":"Harsh Mangalam",
"email":"harshdev8218@gmail.com",
"password":"12345678",
"isAdult":true,
"location":[25.5940947,85.1375645] // [lat,lon]
}
Bun has built in methods to hash passowrd you do not need to install any third party libs like bcryptjs
or argon
.
You can read more about this here Hash a password with Bun
I have create a function reverseGeocodingAPI()
that will accept lat
and lon
to return the location from geoapify
services.
We can configure geoapify
using following steps:-
Step 1
Collect API key from https://www.geoapify.com/
Step 2
Create a new file lib/geoapify.ts
that will handle making api call to geoapify
service and collect location response from there
async function reverseGeocodingAPI(lat: number, lon: number) {
const resp = await fetch(
`https://api.geoapify.com/v1/geocode/reverse?lat=${lat}&lon=${lon}&apiKey=${Bun.env.GEOAPIFY_API_KEY}`
);
const jsonResp = await resp.json();
const data = jsonResp?.features[0]?.properties;
return data;
}
export { reverseGeocodingAPI };
Again we do not need to install any third party libs for making api request like node-fetch
, axios
etc... because Bun support web standared and fetch
is generally available to make api request built into the platform.
Next we will create schema for the body by default elysia use Typebox to provide type safety of request params, body, etc...
src/schema.ts
import { t } from "elysia";
const signupBodySchema = t.Object({
name: t.String({ maxLength: 60, minLength: 1 }),
email: t.String({ format: "email" }),
password: t.String({ minLength: 8 }),
location: t.Optional(t.Tuple([t.Number(), t.Number()])),
isAdult: t.Boolean(),
});
export { signupBodySchema };
Also we are handling errors for duplicate email because prisma throw error for duplicate fields with code P2002
in that case we can return Conflict
status code with 409
.
Implement Log-in
src/route.ts
.post(
"/sign-in",
async ({ body, jwt, cookie: { accessToken, refreshToken }, set }) => {
// match user email
const user = await prisma.user.findUnique({
where: { email: body.email },
select: {
id: true,
email: true,
password: true,
},
});
if (!user) {
set.status = "Bad Request";
throw new Error(
"The email address or password you entered is incorrect"
);
}
// match password
const matchPassword = await Bun.password.verify(
body.password,
user.password,
"bcrypt"
);
if (!matchPassword) {
set.status = "Bad Request";
throw new Error(
"The email address or password you entered is incorrect"
);
}
// create access token
const accessJWTToken = await jwt.sign({
sub: user.id,
exp: getExpTimestamp(ACCESS_TOKEN_EXP),
});
accessToken.set({
value: accessJWTToken,
httpOnly: true,
maxAge: ACCESS_TOKEN_EXP,
path: "/",
});
// create refresh token
const refreshJWTToken = await jwt.sign({
sub: user.id,
exp: getExpTimestamp(REFRESH_TOKEN_EXP),
});
refreshToken.set({
value: refreshJWTToken,
httpOnly: true,
maxAge: REFRESH_TOKEN_EXP,
path: "/",
});
// set user profile as online
const updatedUser = await prisma.user.update({
where: {
id: user.id,
},
data: {
isOnline: true,
refreshToken: refreshJWTToken,
},
});
return {
message: "Sig-in successfully",
data: {
user: updatedUser,
accessToekn: accessJWTToken,
refreshToken: refreshJWTToken,
},
};
},
{
body: loginBodySchema,
}
)
Client will make an api call to /api/auth/log-in
and will send json body
{
"email":"user5@gmail.com",
"password":"12345678"
}
We will verify the email and password from db. Next we will generate two tokens one for Access token
and another for Refresh token
.
We will send both tokens in response cookies so that the further api call to protected route will have those tokens and will store refresh token in db for further use to generate access token.
Again we do not need to add any third party libs for cookies handling Elysia provides all methods to handle cookies.
We will need to add jwt plugin to handle jwt token generation and verification
bun add @elysiajs/jwt
You can read more about jwt plugins here https://elysiajs.com/plugins/jwt.html
Here also we have added login body schema we can add those schema in src/schema.ts
file
...
const loginBodySchema = t.Object({
email: t.String({ format: "email" }),
password: t.String({ minLength: 8 }),
});
export { loginBodySchema, signupBodySchema };
Next we will create new auth plugin that will take care of velidate and verify jwt token when any request will received.
API Request -------> Auth Plugin --------> API Handler
src/plugin.ts
import jwt from "@elysiajs/jwt";
import Elysia from "elysia";
import { JWT_NAME } from "./config/constant";
import { prisma } from "./lib/prisma";
import { User } from "@prisma/client";
const authPlugin = (app: Elysia) =>
app
.use(
jwt({
name: JWT_NAME,
secret: Bun.env.JWT_SECRET!,
})
)
.derive(async ({ jwt, cookie: { accessToken }, set }) => {
if (!accessToken.value) {
// handle error for access token is not available
set.status = "Unauthorized";
throw new Error("Access token is missing");
}
const jwtPayload = await jwt.verify(accessToken.value);
if (!jwtPayload) {
// handle error for access token is tempted or incorrect
set.status = "Forbidden";
throw new Error("Access token is invalid");
}
const userId = jwtPayload.sub;
const user = await prisma.user.findUnique({
where: {
id: userId,
},
});
if (!user) {
// handle error for user not found from the provided access token
set.status = "Forbidden";
throw new Error("Access token is invalid");
}
return {
user,
};
});
export { authPlugin };
Here we have utilized the jwt plugin to verify jwt token received from request cookies. During login we have added userId in jwt sub
and here we have just got the userId and fetch user info from db and added to derive so that available in next request handler.
Here we have raised two error status code
- 401 Unauthorized that can be raise in case of access token is not available
- 403 Forbidden in case of access token is incorrect.
Lets utilize auth plugin in our protected route like /api/auth/logout/
and /api/auth/me
.
Create new route to fetch current user
/src/route.ts
import { authPlugin } from "./plugin";
.use(authPlugin)
.get("/me", ({ user }) => {
return {
message: "Fetch current user",
data: {
user,
},
};
})
We are receiving user in context that are added from derive
in auth plugin.
Lets add new route to logout user
/src/route.ts
.use(authPlugin)
.post("/logout", async ({ cookie: { accessToken, refreshToken }, user }) => {
// remove refresh token and access token from cookies
accessToken.remove();
refreshToken.remove();
// remove refresh token from db & set user online status to offline
await prisma.user.update({
where: {
id: user.id,
},
data: {
isOnline: false,
refreshToken: null,
},
});
return {
message: "Logout successfully",
};
})
After logout we are just removing all the cookies and setting user status to offline.
Create access token from refresh token
/src/route.ts
.post(
"/refresh",
async ({ cookie: { accessToken, refreshToken }, jwt, set }) => {
if (!refreshToken.value) {
// handle error for refresh token is not available
set.status = "Unauthorized";
throw new Error("Refresh token is missing");
}
// get refresh token from cookie
const jwtPayload = await jwt.verify(refreshToken.value);
if (!jwtPayload) {
// handle error for refresh token is tempted or incorrect
set.status = "Forbidden";
throw new Error("Refresh token is invalid");
}
// get user from refresh token
const userId = jwtPayload.sub;
// verify user exists or not
const user = await prisma.user.findUnique({
where: {
id: userId,
},
});
if (!user) {
// handle error for user not found from the provided refresh token
set.status = "Forbidden";
throw new Error("Refresh token is invalid");
}
// create new access token
const accessJWTToken = await jwt.sign({
sub: user.id,
exp: getExpTimestamp(ACCESS_TOKEN_EXP),
});
accessToken.set({
value: accessJWTToken,
httpOnly: true,
maxAge: ACCESS_TOKEN_EXP,
path: "/",
});
// create new refresh token
const refreshJWTToken = await jwt.sign({
sub: user.id,
exp: getExpTimestamp(REFRESH_TOKEN_EXP),
});
refreshToken.set({
value: refreshJWTToken,
httpOnly: true,
maxAge: REFRESH_TOKEN_EXP,
path: "/",
});
// set refresh token in db
await prisma.user.update({
where: {
id: user.id,
},
data: {
refreshToken: refreshJWTToken,
},
});
return {
message: "Access token generated successfully",
data: {
accessToken: accessJWTToken,
refreshToken: refreshJWTToken,
},
};
}
)
Here we are re creating the access token and refresh token from existing refresh token and setting in cookies also we are updating available refresh token in db.
I have added all constants in src/config/constant.ts
const ACCESS_TOKEN_EXP = 5 * 60; // 5 minutes
const REFRESH_TOKEN_EXP = 7 * 86400; // 7 days
const JWT_NAME = "jwt";
export { ACCESS_TOKEN_EXP, REFRESH_TOKEN_EXP, JWT_NAME };
I have created one utility function related to date that will return timestamps from seconds.
src/lib/util.ts
function getExpTimestamp(seconds: number) {
const currentTimeMillis = Date.now();
const secondsIntoMillis = seconds * 1000;
const expirationTimeMillis = currentTimeMillis + secondsIntoMillis;
return Math.floor(expirationTimeMillis / 1000);
}
export { getExpTimestamp };
All the codebase is open source you can access and contribute to repo
https://github.com/harshmangalam/elysia-prisma-jwt-auth
Posted on June 13, 2024
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.