Bug: deleteWorkoutSessionAction has no auth/ownership check — any user can delete others sessions

Author: evan188199-techCreated Jul 7, 2026Updated Jul 7, 2026

Bug

deleteWorkoutSessionAction lets any authenticated user delete any other user's workout session. It's built on the unauthenticated actionClient, fetches the session's userId but never compares it to the caller, and deletes by id alone.

Root cause

src/features/workout-session/actions/delete-workout-session.action.ts:

typescript
export const deleteWorkoutSessionAction = actionClient.schema(...).action(async ({ parsedInput }) => {
  const { id } = parsedInput;
  const session = await prisma.workoutSession.findUnique({
    where: { id },
    select: { userId: true },     // ← fetched, but never compared to anyone
  });
  if (!session) { ... }
  await prisma.workoutSession.delete({ where: { id } });  // ← deletes unconditionally
  ...
});

actionClient (vs authenticatedActionClient) performs no auth and exposes no ctx.user, so the action has no idea who the caller is. The session row's userId is selected and then discarded. Any logged-in user who sends a valid session id deletes it — cross-user data loss.

The codebase already has authenticatedActionClient (used by favorites/get-users) that injects ctx.user via serverAuth(); the delete action just doesn't use it.

Impact

A user can delete another user's workout history by id (guessable/enumerable cuids, leaked via leaderboard/session-share). No audit, no ownership check.

Fix

Switch to authenticatedActionClient and require ownership:

typescript
export const deleteWorkoutSessionAction = authenticatedActionClient
  .schema(deleteWorkoutSessionSchema)
  .action(async ({ parsedInput, ctx }) => {
    const { id } = parsedInput;
    const session = await prisma.workoutSession.findUnique({ where: { id }, select: { userId: true } });
    if (!session || session.userId !== ctx.user.id) {
      return { serverError: "Session not found" };
    }
    await prisma.workoutSession.delete({ where: { id } });
    return { success: true };
  });

Returning the same "not found" for both missing and non-owned prevents existence-disclosure. I have a PR ready.