DEV Community

Cover image for How I Built A Secure, Anonymous Feedback Platform From Scratch
Prashant Swaroop
Prashant Swaroop

Posted on

How I Built A Secure, Anonymous Feedback Platform From Scratch

Motivation and Need

We've all experienced institutions — colleges, companies, organizations — where management collects feedback.

But when it comes to submitting complaints, the process often feels tedious, intimidating, or unclear.

Sometimes it's so complicated that people just give up.

Even if you do manage to submit a complaint, there's no guarantee you won’t face backlash from the administration or individuals involved.

⚡ I wanted to solve this.

I wanted to make a transparent and safe feedback process where:

  • The admin can invite people to submit feedback or complaints,
  • But the identity of the user remains hidden,
  • Ensuring honest feedback without fear.

Thus, I built an Anonymous Feedback Backend system.

Current Mechanism

From what I’ve seen, feedback is often collected using tools like Google Forms.

At first glance, this seems convenient.

But if you look deeper, it’s actually flawed — and here’s why:

  • Most forms collect your email automatically.
  • Every feedback you submit gets tied to your email.
  • Emails are deeply personal — they can be used to identify you easily.

This completely defeats the purpose of honest feedback.

If users know their identity could be revealed —

they will hesitate, filter their words, or worse, stay silent.

And beyond identification, there’s another hidden problem:

There’s no other proper way to verify that the feedback is authentic without collecting personal data.

This is the gap I wanted to fix.

Building a system that ensures:

  • Only invited users can submit feedback,
  • But their identity stays completely hidden,
  • Enabling true, fearless feedback.

My Solution

I built an app that tries to fill this gap.

Let’s quickly walk through how it works:

  • As usual, an admin creates an account.
  • Then, the admin creates topics — each with a title and description — for which they want to collect feedback.
  • Now, the admin uploads user details (name, email, and topicId — which is visible on the frontend).
  • After the emails are uploaded, a token is generated for each user.

    This token is used for verification but is not associated with your email inside the system.

  • Users receive the token on their email.

    When they want to submit feedback, they must enter both their email and the token.

  • Once verified, users can safely submit feedback anonymously.

Note:

The email is only used once — just to verify that you actually received the invitation.

When submitting feedback, only the token is sent — no personal email data is stored or exposed.

#Relax 😌

Er Diagram

SQL TABLE IMAGES

How the Database Design Protects User Privacy

Let's begin with how feedback is only tied to the topic table.

In the feedback table, you can clearly see that no personal data like user email is stored.

Admins can view submitted feedbacks using only the topicId and their own adminId. Beyond that, there is no way to trace which email submitted which feedback.

Challenges:

Challenge 1: Validating legitimate users without storing personal data

To solve this, we require the user to submit both their email and token when giving feedback.

  • The EmailTopics table validates whether this email was invited for that topic.
  • The token is unique and tied to a specific topicId. It allows us to authorize feedback submissions without exposing personal user info.

Challenge 2: Supporting multiple feedback invitations for the same user

One user may be invited to submit feedback on multiple topics.

  • To support this, we designed a one-to-many relationship:
    • One email → Many topics (EmailTopics table)
    • One topic → Many emails (EmailTopics table)

Thus, this approach balances admin control, user privacy, and flexibility.

How Feedback Submission Works Under the Hood

We use both email and token to validate each end user during feedback submission.

  • Email helps us verify whether the user was actually invited to submit feedback.
  • Token helps us validate multiple things:
    • Whether the token is valid and exists.
    • Whether it is tied to a specific topicId.
    • Whether the topic still exists (it could have been deleted by the admin midway).
    • Whether the user has already submitted feedback (if yes, they won't be allowed to submit again).

This dual validation ensures feedback submissions are secure, one-time, and tied only to authorized users, without storing any personal information inside the feedback itself.

const loginUsingToken = async (req: Request, res: Response):Promise<void> => {
    try {
      const { email, token } = userLoginSchema.parse(req.body);

      const [searchEmail, searchToken] = await Promise.all([
        prisma.email.findUnique({ where: { email } }),
        prisma.token.findUnique({ where: { token } }),
      ]);

      if (!searchEmail || !searchToken) {
         res.status(400).json({
          success: false,
          message: "Auth failure against token and email provided.",
        });
        return;
      }

      // Check token expiry
      if (new Date() > searchToken.expiresAt) {
        res.status(409).json({
          success: false,
          message: "We are no longer accepting feedback. Feedback time is over.",
        });
        return;
      }

      // Check if token is used
      if (searchToken.isUsed) {
        res.status(409).json({
          success: false,
          message: "Token has already been used to submit feedback.",
        });
        return;
      }

      // Check if email is associated with the topic from token
      const isAssociated = await prisma.email.findFirst({
        where: {
          id: searchEmail.id,
          topics: {
            some: { id: searchToken.topicId },
          },
        },
      });

      if (!isAssociated) {
        res.status(401).json({
          success: false,
          message: "You are not allowed to submit feedback to this topic.",
        });
        return;
      }

      // All good
       res.json({
        success: true,
        message: "Logged in successfully",
        tokenId: searchToken.id,
        topicId: searchToken.topicId,
      });

    } catch (error) {
      console.error("Error during token login:", error);

      if (error instanceof Prisma.PrismaClientKnownRequestError) {
         res.status(500).json({
          success: false,
          message: "Internal server error happened at our side (DB)",
        });

        return;
      }

      res.status(500).json({
        success: false,
        message: "Internal server error happened at our side.",
      });

      return;
    }
  };
Enter fullscreen mode Exit fullscreen mode

Mistakes I Made in v0

  • I totally forgot to add a logout route for the admin.

    (The only way out was letting the JWT expire — not a great thing to do, apparently.)

  • I created three separate routes:

    1. Uploading emails
    2. Generating tokens
    3. Sending emails

      This created a lot of friction for the end users, who now had to make too many decisions manually.

  • I was using memory storage for processing uploaded CSV files.

    This was not scalable — I should have used a blob or file service.

    Since I was saving uploads to a local /uploads folder, it broke in deployment (because cloud servers have temporary file storage that often resets or errors out).

  • Looking back...

This gives me chills — a lot of rookie mistakes were made here.

What I Fixed in v1

  • For the auth system — I didn’t just create a logout endpoint.

    I went beyond expectations. (Not just flexing, it’s the real deal, brahh. 😎)

  • I built a full authentication system with:

    • Refresh Tokens
    • Session Management
    • IP and device tracking
    • Expiry tracking for each session
    • Secure storage of refresh tokens inside **HttpOnly cookies**.
  • On logout, we now clear the refresh token (mark it revoked), terminate the session, and save when/where/how it was terminated.

    This makes the auth system robust, secure, and modern in every sense.

About the endpoints problem:

I reduced three endpoints to just two.

I automated two flows and merged them together.

Again, went a bit beyond normal expectations.

  • I used transactions to merge uploading emails and generating tokens into a single atomic operation.

    Now it’s automated, more robust, and error-resistant

For the storage problem:

  • I stopped using disk storage.
  • I used memory storage instead.
  • Once the file is available in RAM, we parse it directly from buffers and send all the email data using a middleware function — no more temp files!

✅ This made the whole system faster, more scalable, and ready for real-world deployment.

Talk Is Cheap Show me the code PRASHANT you flex too much we shall see your code 🤔

Logout endpoint code

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

  try {

    const refreshToken = req.cookies.refreshToken;

    if(!refreshToken){
      res.status(400).json({
        success:false,
        message:"No refresh cookie could be found"
      })

      return;
    }

    const validateRefreshToken = await prisma.refreshToken.findUnique({
      where:{
        id:refreshToken
      }
    })

    if(!validateRefreshToken){
      res.status(400).json({
        success:false,
        message:"Invalid refresh token!"
      })
      return;
    }
    else if(validateRefreshToken.revoked == true ||validateRefreshToken.expiresAt <= new Date()){
      res.status(400).json({
        success:false,
        message:"Expired or already used refresh tokens"
      })

      return;
    }

    await prisma.refreshToken.update({where:{id:validateRefreshToken.id}, data:{revoked:true}})

    await prisma.session.update({where:{refreshToken:validateRefreshToken.id}, data:{revoked:true, revokedAt: new Date()}})

    res.clearCookie("refreshToken")

    res.json({
      success:true,
      message:"Logged out successfully"
    })

  } catch (error) {
    console.error("Error happened while logging out", error)
    res.status(500).json({
      success:false,
      message:"Internal server error"
    })

  }

}
Enter fullscreen mode Exit fullscreen mode

🔄 Refresh Access Token Endpoint

Since my access tokens are short-lived (for better security),

I built a refresh access token endpoint to allow users (admins) to stay logged in without forcing them to sign in again and again.

Just to be honest — I added this because I felt like it 😎 — but also because real-world auth flows need this for a good user experience.

This endpoint checks the refresh token (securely stored in HttpOnly cookies),

verifies it, and issues a new access token without touching the user session unnecessarily.

It's a small addition but it makes the auth system feel buttery smooth.

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

    const refreshToken = req.cookies.refreshToken;

    if(!refreshToken){
      res.status(400).json({
        success:false,
        message:"Refresh Token in unavailable"
      })
      return;
    }

    const validateRefreshToken = await prisma.refreshToken.findUnique({
      where:{
        id:refreshToken
      }
    })

    if(!validateRefreshToken){
      res.status(400).json({
        success:false,
        message:"Invalid refresh token!"
      })
      return;
    }
    else if(validateRefreshToken.revoked == true ||validateRefreshToken.expiresAt <= new Date()){
      res.status(400).json({
        success:false,
        message:"Expired or already used refresh tokens. Please login again."
      })
      return;
    }

    const adminDetails = await prisma.admin.findUnique({
      where:{
        id:validateRefreshToken.adminId
      }
    })

    const accessToken = jwt.sign(
      {
        name: adminDetails?.name,
        id: validateRefreshToken.adminId,
        email: adminDetails?.email,
      },
      JWT_SECRET,
      {
        algorithm: "HS256",
        issuer: "ADMIN",
        expiresIn: "15m",
      }
    );

    res.json({
      success:true,
      message:"Token generated successfully",
      accessToken
    })


  } catch (error) {

    console.error("Error happened while refreshing access token", error);

    res.status(500).json({
      success:false,
      message:"Internal server error happened at our end"
    })

  }
}
Enter fullscreen mode Exit fullscreen mode

Finally, the best thing — saved for last:

I love the Prisma developers from the bottom of my heart.

They made atomicity and transactions come to life with such simple syntax.

I literally jumped out of my chair when I got my transaction code working for the first time! 😄

Until now, I had only heard about rollback, atomicity, and consistency...

but implementing it myself felt absolutely surreal.

Big corporations use transactions to power things like UPI payments, net banking, and huge financial systems.

And today, my small little app uses it too. ✨

Enough talk — here’s the code.

Go ahead, implement it yourself.

Fall in love with the beauty of low-level programming. 🚀

const saveEmails = async (req:RequestWithPayload, res:Response)=>{
    try {

        const id:number = req.user?.id!

        const topicId = req.params.id; // to create and save tokens 

        if(!topicId){
            res.status(400).json({
                success:false,
                message:"To send emails in next process. it needs topicId as params."
            })
        }

        const emailsWithTopics = req.emailsWithTopics

        console.log(emailsWithTopics, "email with topics")

        const dataToInsert = (req.emailsWithTopics ?? []).map(entry => ({
            name: entry.name || null,
            email: entry.email,
            topicId: parseInt(entry.topicId),
            adminId: id,
        }))

// Main Part 
        await prisma.$transaction(async(tx)=>{

            for (const entry of dataToInsert){
               const email = await tx.email.upsert({
                    where:{
                        email: entry.email
                    },
                    update:{
                        name:entry.name
                    },
                    create:{
                        email:entry.email,
                        name:entry.name,
                        admin: {connect: {id: entry.adminId}} //adminId:entry.adminId
                    }
                })
// first we saved each emails 
// now we update each emails so that it can be associated with topics 
                await tx.email.update({
                    where:{
                        id:email.id
                    },
                    data:{
                        topics: {connect: {id: parseInt(topicId)}}
                    }
                })
            }

            const emailCount = await tx.email.count({
                where:{
                    topics:{
                        some:{
                            id: parseInt(topicId)
                        }
                    }
                }
            })
            // we counted no of emails created/saved now will generate unique token for this


            // generateTokens against emails
            const nTokens = await createNToken(emailCount);

            // mapout tokens to save in database
            const tokenToInsert = nTokens.map((val)=>({
                token:val,
                topicId:parseInt(topicId),
                expiresAt:new Date(Date.now() + 7 * 24 * 60 * 60 * 1000)
            }))

            // save tokens to database

            await tx.token.createMany({
                data: tokenToInsert,
            })

            // finally end of transaction.
        })

        res.status(200).json({
            success:true,
            message:"Emails and Tokens are successfully added to database 🥳"
        })


    } catch (error) {

        console.error("Error happened at processing transaction 😔", error);
        res.status(500).json({
            success:false,
            message:"Internal server error"
        })

        return;

    }
}
Enter fullscreen mode Exit fullscreen mode

🎤🎙️End

This is why we build.

This is why we code.

This is why we fall in love with tech.

I have add GitHub link and postman link, go fork it make it bad ,ugly mess, weep, cry fix it.

GitHub Link:-https://github.com/PRASHANTSWAROOP001/-Anonymous-Feedback-System

Postman Link:- https://documenter.getpostman.com/view/38176982/2sB2j1gXe1

Top comments (4)

Collapse
 
nevodavid profile image
Nevo David

Insane level of detail, honestly I wish I had something like this back when I was dealing with anonymous feedback nightmares in college.

Collapse
 
prashantswarop9 profile image
Prashant Swaroop

Thanks 💗 for the feedback.

Collapse
 
michael_liang_0208 profile image
Michael Liang

Great post!

Collapse
 
prashantswarop9 profile image
Prashant Swaroop

Thanks I Hope You Have Enjoyed It. 🤗💖