DEV Community

Cover image for Never Use a Display Name for Authorization: Secure Anonymous Editing
Janarthanan Soundararajan (Jana)
Janarthanan Soundararajan (Jana)

Posted on AI-assisted

Never Use a Display Name for Authorization: Secure Anonymous Editing

Public polls, surveys, and collaborative boards often ask participants for a name. The name helps other people understand who submitted each response.

It should not grant permission to edit that response.

A display name helps humans identify a participant. It does not prove that an incoming HTTP request came from the person who created the record. Treating identification as authorization creates a serious security flaw in applications that allow anonymous participation.

Anonymous editing needs a separate credential. One practical option is a cryptographically random token stored in an HttpOnly cookie, with only its SHA-256 hash saved in the database.


Identification and authorization

Consider a row of lockers. Each locker has a name written on it.

The name helps you locate the correct locker. It cannot open the door. You still need the key.

┌────────────────────────────────────────────────────────┐
│                      THE LOCKER                        │
│                                                        │
│  Label (Display Name)  ──▶  Identifies the record      │
│  Key (Session / Token) ──▶  Authorizes modifications   │
│  Interior (Choices/Data)──▶  The protected resource    │
└────────────────────────────────────────────────────────┘

Enter fullscreen mode Exit fullscreen mode

The same distinction applies to application data:

  • A display name tells people which participant submitted a response.
  • A session or token proves that the current request may modify it.

Code such as this treats public user input as an authorization credential:

// ❌ Dangerous: Treating user input as an authorization credential
await db.availability.deleteMany({
  where: {
    pollId,
    userId: null,
    participantName: {
      equals: participantName,
      mode: "insensitive",
    },
  },
});

Enter fullscreen mode Exit fullscreen mode

Anyone who knows or guesses the participant’s name could overwrite that person’s response.

Display names are unsuitable for authorization because they are publicly visible, easy to guess, and often shared by multiple people. They also introduce casing and formatting ambiguities.

Identification answers: “Which record are you referring to?”
Authorization answers: “Are you allowed to modify it?”


Two ownership paths

An application that supports both accounts and anonymous participation needs separate authorization paths:

Incoming Request
       │
       ├─▶ Has Session? ──────▶ Verify session userId
       │
       └─▶ Anonymous Guest? ──▶ Verify HttpOnly edit token hash

Enter fullscreen mode Exit fullscreen mode

Authenticated participants

For signed-in users, the server reads the user ID from the active session. It should never trust a user ID supplied in the request body or query parameters.

A composite unique constraint on pollId and userId can enforce one participant record per authenticated user:

const participant = await tx.pollParticipant.upsert({
  where: {
    pollId_userId: {
      pollId,
      userId,
    },
  },
  update: {
    displayName: participantName,
    email: participantEmail ?? null,
    responseStatus: "RESPONDED",
    respondedAt: new Date(),
  },
  create: {
    pollId,
    userId,
    displayName: participantName,
    email: participantEmail ?? null,
    responseStatus: "RESPONDED",
    respondedAt: new Date(),
  },
});

Enter fullscreen mode Exit fullscreen mode

The participant can later change their display name without losing ownership. Their edit permission depends on the immutable userId, not the name shown in the interface.


Anonymous participants

An anonymous participant has no account or server session. The server therefore needs to issue a separate credential that grants access to that participant’s response.

Generate the credential with a cryptographically secure random-number generator:

import { randomBytes, createHash } from "node:crypto";

// Generate 32 bytes of cryptographically secure entropy
const editToken = randomBytes(32).toString("base64url");

Enter fullscreen mode Exit fullscreen mode

Store the raw token in a restricted browser cookie:

cookieStore.set(cookieName, editToken, {
  httpOnly: true,
  sameSite: "lax",
  secure: process.env.NODE_ENV === "production",
  path: "/",
  maxAge: 60 * 60 * 24 * 90, // 90 days
});

Enter fullscreen mode Exit fullscreen mode

Each cookie option has a specific purpose:

  • httpOnly prevents client-side JavaScript from reading the token. This reduces direct token exfiltration through XSS, although it does not remove every XSS risk.
  • secure restricts the cookie to HTTPS connections in production.
  • sameSite: "lax" helps block common cross-site request forgery attempts.
  • maxAge limits how long the participant can use the token to edit the response.

Store the hash, not the raw token

A raw edit token is a bearer credential. Anyone who obtains it can use it.

Saving raw tokens in the database creates unnecessary exposure. If an attacker gains read access to the database, they could copy those tokens and edit active responses.

Hash the token before storing it:

const hashEditToken = (token: string) =>
  createHash("sha256").update(token).digest("hex");

Enter fullscreen mode Exit fullscreen mode

The request flow then looks like this:

Browser                           Server                         PostgreSQL
   │                                │                                │
   │── Send Cookie (Raw Token) ────▶│                                │
   │                                │── SHA-256(Raw Token) ─────────▶│
   │                                │                                │
   │                                │◀─ Find record matching hash ───│
   │                                │                                │
   │◀── Allow / Reject Mutation ────│                                │

Enter fullscreen mode Exit fullscreen mode

When the participant returns:

  1. The browser sends the raw token through the HttpOnly cookie.
  2. The server hashes the token with SHA-256.
  3. The database query searches for an anonymous participant matching both the pollId and editTokenHash.
  4. The server permits the update only when that record exists.
const participant = await tx.pollParticipant.findFirst({
  where: {
    pollId,
    editTokenHash: hashEditToken(existingEditToken),
  },
});

Enter fullscreen mode Exit fullscreen mode

Why SHA-256 is suitable here

Argon2 and bcrypt are deliberately slow because human-created passwords usually have limited entropy. Their cost makes dictionary attacks more expensive.

A token generated from 32 random bytes has 256 bits of entropy. It is not vulnerable to the same kind of password guessing, assuming it was generated securely. SHA-256 is therefore suitable for storing a lookup value without adding the CPU cost of password hashing.


Example: Normalized Polling Architecture in SlotSyncro

To see this pattern in practice, consider SlotSyncro, an open-source meeting poll application built with Prisma and PostgreSQL.

In SlotSyncro, participants vote on meeting options using three preference states: Yes, If needed, and No. To keep authorization checks independent of voting preferences, the participant record is stored separately from individual slot choices:

model PollParticipant {
  id             String             @id @default(cuid())
  pollId         String
  userId         String?
  displayName    String
  email          String?
  responseStatus PollResponseStatus @default(PENDING)
  respondedAt    DateTime?
  editTokenHash  String?            @unique

  poll        Poll             @relation(fields: [pollId], references: [id])
  preferences PollPreference[]

  @@unique([pollId, userId])
}

model PollPreference {
  id            String             @id @default(cuid())
  pollId        String
  participantId String
  candidateId   String
  status        AvailabilityStatus @default(YES)

  participant PollParticipant @relation(
    fields: [participantId, pollId],
    references: [id, pollId]
  )
  candidate PollCandidate @relation(
    fields: [candidateId, pollId],
    references: [id, pollId]
  )

  @@unique([participantId, candidateId])
}

Enter fullscreen mode Exit fullscreen mode

This separation gives the application a clear ownership boundary. The server first verifies access to PollParticipant. After that check succeeds, it can modify preferences through the validated participant ID.

The composite relationships also prevent cross-poll mismatches. A preference associated with Poll A cannot reference a participant or candidate belonging to Poll B.

Preference replacement happens inside one database transaction:

await db.$transaction(async (tx) => {
  // 1. Clear existing choices for this validated participant
  await tx.pollPreference.deleteMany({
    where: { participantId },
  });

  // 2. Insert new selections
  await tx.pollPreference.createMany({
    data: votes.map(({ slotId, status }) => ({
      pollId,
      participantId,
      candidateId: slotId,
      status,
    })),
  });
});

Enter fullscreen mode Exit fullscreen mode

If either operation fails, the transaction rolls back. The response cannot be left with its old preferences deleted and only some of the new preferences inserted.


Limitations of cookie-backed editing

The token protects anonymous responses from name-based hijacking, but it ties edit access to one browser profile.

A participant may lose access when:

  • They switch to another device or browser.
  • They clear their cookies.
  • They use a private browsing window that is later closed.

Creating a new response is safer than falling back to an unverified name or email address.

Applications that need cross-device editing without requiring a full account can add an out-of-band recovery mechanism. Options include a signed, time-limited edit link sent by email or a flow that lets a signed-in user claim an anonymous response.

Rate limiting can also reduce automated response creation and token-generation abuse. It should be treated as an abuse-control measure, not proof of ownership.


Authorization checklist

Property Display Name Opaque Edit Token
Primary role Identification (UI context) Authorization (permission to edit)
Entropy Extremely low (human-selected) Cryptographically secure (32 bytes)
Visibility Public to all viewers Private (client cookie and hash in database)
Collisions Common Mathematically negligible

Keep display names as labels for people.

Any request that modifies an existing anonymous response must present a credential that the server can verify. Depending on the application, that credential may be an authenticated session, an opaque edit token, or a signed edit link. A publicly visible name is never enough.

Top comments (1)

Collapse
 
janarthanan_soundararajan profile image
Janarthanan Soundararajan (Jana)

For anyone interested in the concrete implementation, this article came out of engineering the guest polling flow for SlotSyncro (a portfolio app I'm building with Next.js, Prisma, and PostgreSQL):

🔗 github.com/TechAaroorian/slotsyncro

It's an active work-in-progress, but the code shows the actual schema constraints and cookie-hash verification helpers in action. Feedback on the database model is always welcome!