Skip to main content

Command Palette

Search for a command to run...

How to Implement OAuth 2.0/OIDC in React, Express and TypeScript with Prisma and implementation of Admin Privileges

Simplifying OAuth 2.0 and OIDC Setup for Authentication and Admin Authorization in Full-Stack Apps

Updated
10 min read
How to Implement OAuth 2.0/OIDC in React, Express and TypeScript with Prisma and implementation of Admin Privileges

In my previous blog, I focused mainly on the theoretical aspects of OAuth 2.0 and OpenID Connect. Now it’s time to bring those concepts into practice. For this blog , I’ve built a dedicated Proof of Concept (POC) to demonstrate a real-world implementation. I’ve also included additional authorization features, such as an admin-level privilege layer, to make the demo more realistic.

The POC is built with a React frontend and an Express + TypeScript backend, implementing OAuth from scratch without using any OAuth-specific frameworks .

The deployment setup is fully production-style:
• Frontend on Vercel.
• Backend on AWS EC2.
• NGINX used for load balancing across my other services and handling SSL termination.
• A rate limiter implemented at the backend level to protect the EC2 instance from abuse, excessive requests, and potential denial-of-service scenarios. This ensures better stability, security, and controlled resource usage under real-world traffic patterns.

(If you’re interested in learning more about NGINX configuration , rate limiters or deploying services on EC2, I’ll cover those topics in future blogs depending on the engagement this series receives) .

Rather than swaying here and there , let’s focus on the topic currently on hand → so then lets go to system design we will follow for implementing authentication here -

We can use this pattern for any of the auth providers , but for sake of simplicity , we will follow google OAuth / OIDC services .

We will break the approach in three basic parts -

  1. Password based Authentication .

  2. OIDC/OAuth based Authentication .

  3. Admin level authorization ( bonus ) + way to set password after logging in via google + Two Token Setup .

before going into each of the steps , lets look into the schema first -

enum Auth {
    GoogleLogin
    PasswordLogin
    Both
}

model User {
  id            String    @id @default(uuid())
  email         String    @unique        // required + unique
  passwordHash  String?                   // for email/password login
  googleId      String?   @unique         // for Google OAuth 
  name          String                    // required
  refreshToken  String?   @unique         // only one refresh token per user
  createdAt     DateTime  @default(now())
  updatedAt     DateTime  @updatedAt
  authType      Auth      @default(PasswordLogin)
}

apart from general user data , we are using passwordHash , googleId and authType for handling both authentication methods ,

  • passwordHash used to store the hashed password in case of email / password login

  • googleId used to store the id received from google in case of google login

  • authType tells which type of auth is used , google , password or both

Password Based Authentication

In Password Based Authentication ,

Both Login and Registration ( account creation ) will take place in two steps -

we will take email and password from the user , check if the password and email are correct and if so then we give the required cookies ( namely access token and refresh token ) to the client browser and log in the user.

Let’s discuss how to implement Email Password Login -

Firstly , user will fill a form with email and password and then a POST request will be sent from the client side to the application server -

await axios.post(
        `${BACKEND_URI}/v1/auth/login`,
        {
          email: data.email,
          password: data.password,
        },
        {
          headers: {
            "Content-Type": "application/json",
          },
          withCredentials: true,
        }
      );

And now on the application server , we will deal with the data , check in the db if the user exists and then issue access and refresh token to the client browser based on the availability of user account .

        const {email , password} = req.body
        if(!(email && password)) throw new ApiError(411,"Either email or password is missing")

        const user = await db.user.findUnique({
            where : {
                email
            }
        });

        if(!user?.passwordHash) {
            throw new ApiError(411,"Either account not present or logged in through google")
        }

        const passwordCheck = await bcrypt.compare(password,user.passwordHash);

        if(!passwordCheck) throw new ApiError(406,"password is not correct");

        const accessToken = generateAccessToken(user.id);
        const refreshToken = generateRefreshToken(user.id);

        const loggedInUser = await db.user.update({
            where : {
                id : user.id
            },
            data:{
                refreshToken
            },
            select:{
                id:true,
                name:true,
                authType:true,
            }
        });



        return res
        .status(201)
        .cookie("accessToken",accessToken,accessTokenCookieOption)
        .cookie("refreshToken",refreshToken,refreshTokenCookieOption)
        .json({
            message:"user logged in",
            data: loggedInUser
        })

As we are already discussing about logging in the user lets also look into creating a user account . Similar to Login , a form will be filled in the client browser and data will be sent to the application server via a POST request .

await axios.post(
        `${BACKEND_URI}/v1/auth/register`,
        {
          name: data.name,
          email: data.email,
          password: data.password,
          type:"PasswordLogin"
        },
        {
          headers: {
            "Content-Type": "application/json",
          },
        }
      );

and on the application server side as well most of the things will remain the same . We will first check if the user exists in the db , if not we will hash the password and save the user data along with passwordHash in the db .

        const { name,email,password } = req.body ;
        if(!(name && email && password)){
           throw new ApiError(411,"either of name , email & password is missing")
        } 

        const existingUser = await db.user.findUnique({
            where:{
                email,
            }
        })

        if(existingUser) throw new ApiError(411,"User already exists")

        const hashedPassword = await bcrypt.hash(password,10);


        const createdUser = await db.user.create({
            data:{
               name,
               email,
               passwordHash:hashedPassword,
               authType:"PasswordLogin"
            }
        });

        return res.status(201).json({
            message:"createdSuccessfully",
            data:createdUser
        })

This is mainly how we deal with Password Based Authentication in general .

Now moving to the main part of the blog handling Authentication with OIDC/OAuth

OIDC/OAuth Authentication

Firstly get the credentials ( namely , client secret and client id ) from the auth provider , for this blog it will be google .

In the frontend have a sign in/sign up with google button , which will basically redirect to authorization server , along with redirect uri and client id as url param .

<Button
       variant="outline"
       className="w-full text-white cursor-pointer"
       disabled={isSubmitting}
       type="button"
       onClick={()=>{
                  window.location.href = `https://accounts.google.com/o/oauth2/v2/auth
                                          /oauthchooseaccount?
                                          redirect_uri=${GOOGLE_REDIRECT_URI}
                                          &prompt=consent
                                          &response_type=code
                                          &client_id=${GOOGLE_CLIENT_ID}
                                          &scope=${GOOGLE_LOGIN_SCOPE}`
                }}           
       >

So in the start our user will click the sign in / sign up button and will be redirected to the authorization server which will handle the remaining part of authentication .

which will look some thing like this -

once the user authenticates with our auth provider they will be guided to consent page .

Now once user provides the consent , they will be redirected to callback or redirect uri ( with auth code as url param ) , in this case we are keeping a frontend or client side uri as redirect uri .

Then from frontend we will send the auth code to our web server via POST request .

while we get the response from our server , our client will see a loading screen .


export function OAuthPage() {

  const navigate = useNavigate();

  const oauthHandler = useCallback(async () => {
    const searchParams = new URLSearchParams(window.location.search);

    for (const [key, value] of searchParams) {
        if(key == "code"){
           try {
            await axios.post(`${import.meta.env.VITE_BACKEND_URI}/v1/auth/googleOAuth`,{
                 authorizationCode : value
           },{
            withCredentials:true
           })

           navigate("/");
           } catch (error) {
            console.log(error)
           }

           return ;
        }else if(key=="error"){
           navigate("/auth/google/error")
        }
    }

  }, [navigate])


  useEffect(() => {
    oauthHandler()
  }, [oauthHandler]);

  return (
    <div>
      <Loader/>
    </div>
  )
}

Now as the web server gets the auth code , it does the following -

const oAuthHandler = async (req:Request,res:Response) => {

    const { authorizationCode } = req.body;

    try {

    if(!authorizationCode) throw new ApiError(401,"authorization code absent") ;
    const googleAuthorizationServer = "https://oauth2.googleapis.com/token";
    const httpMethod = "POST";

      const tokenRes = await fetch(googleAuthorizationServer,{
        method:httpMethod,
        headers:{"Content-Type": "application/x-www-form-urlencoded"},
        body:new URLSearchParams({
            code:authorizationCode,
            client_id:process.env.GOOGLE_CLIENT_ID!,
            client_secret:process.env.GOOGLE_CLIENT_SECRET!,
            redirect_uri:`${process.env.ORIGIN}/auth/google/callback`,
            grant_type: "authorization_code",
        })
      }) // 1

      const token = await tokenRes.json();

      const googleAccessToken = token.access_token;

      // fetch user info 

      const userProfile = await fetch("https://www.googleapis.com/oauth2/v2/userinfo",{
         headers:{
            Authorization:`Bearer ${googleAccessToken}`
         }
      }) // 2

      const userInfo:userInfoType = await userProfile.json();

      const user = await db.user.upsert({
           where:{
              email:userInfo.email,
           },
           update:{
              googleId:userInfo.id,
              authType: userInfo.authType === "PasswordLogin" ? "Both" : "GoogleLogin" ,
           },
           create:{
              name:userInfo.name,
              email:userInfo.email,
              authType:"GoogleLogin",
              googleId:userInfo.id,
           }
      }); // 3


      const accessToken = generateAccessToken(user.id);
      const refreshToken = generateRefreshToken(user.id);

      const loggedInUser = await db.user.update({
            where : {
                id : user.id
            },
            data:{
                refreshToken
            }
        });

       return res
        .status(201)
        .cookie("accessToken",accessToken,accessTokenCookieOption)
        .cookie("refreshToken",refreshToken,refreshTokenCookieOption)
        .json({
            message:"user logged in",
            data: loggedInUser
        }) // 4



    } catch (error:any) {
        const err : ApiErrorTypes = error as ApiErrorTypes ;

        const statusCode = typeof err.status === "number" ? err.status : 501 ; 
              return res
              .status(statusCode)
              .json({
                  message:err.message,
                  status:"fail"
              })  
    }
}

So our web server will do basically 4 things -

  1. Get the access token from the auth provider by sending a POST request to the authorization server with the auth code, other credentials, and the client secret, which is kept only on the web server to prevent exposure on the client side.

  2. Fetch the user data from the resource server using the access token( provided by the authorization server ) .

  3. Upsert the user data in the database. If the user previously logged in using the PasswordLogin method, mark the user’s authType as Both; otherwise, set it to GoogleLogin.

  4. Generate the access and refresh tokens, store the refresh token in the database, and set both tokens as cookies in the browser.

Once the cookies are set in the browser, the user is considered logged in. This is essentially how the OAuth/OIDC protocol is handled for authentication.


Now as promised earlier , we will discuss two other functionality -

  1. Enabling users who signed up with GoogleLogin to also add password-based authentication to their account.

     const openIdPasswordAdditionAndChange = async (req:Request,res:Response) => {
          try {
    
             // switch to email - password or just change password 
    
             const { newPassword , changeMode , id } = req.body ;
    
             if(!newPassword) throw new ApiError(400,"password cannot be empty");
    
             const passwordHash = await bcrypt.hash(newPassword,10);
    
             let userData ;
             if(!changeMode){
                  userData = await db.user.update({
                     where:{id},
                     data:{
                         passwordHash,
                         googleId:null,
                         authType:"PasswordLogin"
                     }
                  })
             }else{
                 userData = await db.user.update({
                     where:{id},
                     data:{
                         passwordHash,
                         authType:"Both"
                     }
                 })
             }
    
             return res.status(200).json({
                 message:"password change",
                 data: userData
    
             })
    
          } catch (error:any) {
             const err : ApiErrorTypes = error as ApiErrorTypes ;
             const statusCode = typeof err.status === "number" ? err.status : 501 ; 
                   return res
                   .status(statusCode)
                   .json({
                       message:err.message,
                       status:"fail"
                   }) 
         }
     }
    

    This endpoint allows users who initially authenticated via Google Login to either add password-based authentication or switch entirely to email–password login.

    If changeMode is enabled, the user retains Google Login and the authType is updated to Both. Otherwise, Google Login is removed and the account is converted to PasswordLogin.

Adding an admin authorization layer that allows viewing all users, and deleting or logging out specific user accounts.

For this , we will first have an admin login form -

and data is sent to the web server for authentication via POST request which will authorize the user , and convert them from user to admin role .

        const { username , password } = req.body;

    if( username !== ADMIN_SECRET_USERNAME || password !== ADMIN_SECRET_PASSWORD)
         throw new ApiError(401,"incorrect admin credentials")

    if(!process.env.ADMIN_SECRET_KEY) 
         throw new ApiError(501,"Admin secret key absent in server")

    const encryptedKey = await bcrypt.hash(process.env.ADMIN_SECRET_KEY,10);

    const adminToken = await generateAdminToken(encryptedKey)

    return res
    .status(201)
    .cookie("adminToken",adminToken,adminCookieOptions)
    .json({
        message : "admin privileges added to the account",
        success : true
    })

Once the user logs in as an admin they will see a dashboard with all the users , their authentication methods and other user data , and will have functionality of deleting or logging out their respective accounts .

Here are the code for all of these functions -

For getting all the users and their data

 const userData = await db.user.findMany({
              select: {
                    id: true,
                    email: true,
                    name: true,
                    createdAt: true,
                    updatedAt: true,
                    authType: true,
                    refreshToken: true,
                }
        });


        return res.status(200)
         .json({
        users : userData,
        success : true 
        })

Logging out specific user , we will send the id of user to logged out and then set their refresh token to null .

        const { id } = req.body ;

        await db.user.update({
            where : { id },
            data : {
                refreshToken:null
            }
        });

        return res.status(201).json({
            message:"user successfully logged out",
            status:"success"
        })

and at last , Delete a specific user account , here we will send the user id via params from the frontend and delete the user completely from the db .

       const userId = req.params.id as string ;

        const user = await db.user.delete({
            where:{
                id:userId
            }
        });


        return res.status(201).json({
            message:"user deleted successfully",
            user:user,
            status:"success"
        })

That’s it for this blog.

If you want to see everything in action, you can check out the live POC here: POC Link

You can also find the complete code on GitHub here : Repo Link

As I mentioned at the end of my last blog, I’m starting a series around authentication and everything that comes with it. This article covered implementing OAuth 2.0 and OpenID Connect end to end, along with real-world considerations like token handling, account linking, and admin-level authorization.

Also, there was a bit of a hiatus of around 2–3 months, which was longer than I had initially planned, mainly due to office work and other commitments. I’m hoping to keep things more consistent going forward.

In the upcoming posts, I’ll dive deeper into topics like OTP-based authentication, magic links, email verification, password recovery, and other common auth flows you’d see in production systems.

Meanwhile, if you want to strengthen your understanding of OAuth 2.0 and OpenID Connect, this video from Okta’s YouTube channel is a great resource: Okta Vid Link

Thanks for reading, and see you in the next one .