Skip to main content
Private Chat uses a lightweight, cookie-based authentication system. No user accounts, passwords, or OAuth flows—just automatic token generation and room-specific access control.

How it works

Authentication is handled transparently:
  1. When you visit a room URL, the middleware checks if you have an auth token
  2. If not, a unique token is generated and stored in a cookie
  3. The token is added to the room’s participant list in Redis
  4. All API requests validate the token before allowing access

Token generation

Tokens are created in the middleware when you first access a room:
src/proxy.ts
const existingToken = req.cookies.get("x-auth-token")?.value;

// USER IS ALLOWED TO REJOIN
if (existingToken && meta.connected.includes(existingToken)) {
  return NextResponse.next();
}

// USER IS NOT ALLOWED TO REJOIN
if (meta.connected.length >= 2) {
  return NextResponse.redirect(new URL("/?alert=room-full", req.url));
}

const response = NextResponse.next();
const token = nanoid();

response.cookies.set("x-auth-token", token, {
  path: "/",
  httpOnly: true,
  secure: process.env.NODE_ENV === "production",
  sameSite: "strict",
});

await redis.hset(`meta:${roomId}`, {
  connected: [...meta?.connected, token],
});
Tokens are generated using nanoid, the same library used for room IDs. They’re cryptographically random and collision-resistant.
The x-auth-token cookie is configured with:
  • httpOnly - Prevents JavaScript access, protecting against XSS attacks
  • secure - HTTPS-only in production
  • sameSite: strict - Prevents CSRF attacks
  • path: / - Available across the entire site

API authentication

All API endpoints (except room creation) require authentication:
src/app/api/[[...slugs]]/auth.ts
export const AuthMiddleware = new Elysia({ name: "auth" })
  .error({ AuthError })
  .onError(({ code, set }) => {
    if (code === "AuthError") {
      set.status = 401;
      return { error: "Unauthorized" };
    }
  })
  .derive({ as: "scoped" }, async ({ query, cookie }) => {
    const roomId = query.roomId;
    const token = cookie["x-auth-token"].value as string | undefined;

    if (!roomId || !token) {
      throw new AuthError("Missing Room ID or Token.");
    }

    const connected = await redis.hget<string[]>(`meta:${roomId}`, "connected");

    if (!connected?.includes(token)) {
      throw new AuthError("Invalid token");
    }

    return { auth: { roomId, token, connected } };
  });
The middleware:
  1. Extracts the token from the x-auth-token cookie
  2. Fetches the room’s participant list from Redis
  3. Verifies the token is in the list
  4. Returns 401 Unauthorized if validation fails
If your cookie is missing or invalid, you’ll get a 401 error when trying to access the room.

Username generation

Users don’t create accounts, so they need anonymous identities. Usernames are automatically generated using a combination of random words, animals, and a unique ID:
src/hooks/use-username.ts
const randomWord = async (): Promise<string> => {
  const response = await fetch('https://random-word-api.herokuapp.com/word');
  const word = await response.json();
  return word[0];
};

const randomAnimal = async () => {
  const response = await fetch('https://random-animal-api.vercel.app/api/random-animal');
  const word = await response.json();
  return word["city"].toLowerCase().split(" ")[0];
};

const generateUsername = (randomWord: string, randomAnimal: string) => {
  return `${randomWord}-${randomAnimal}-${nanoid(5)}`;
};
Example generated username: clever-fox-aB3d9 Usernames are:
  • Generated once - On first visit, combining a random word + random animal + 5-character ID
  • Stored in localStorage - Persists across browser sessions using the key custom-username
  • Not validated server-side - Only the max length (100 characters) is validated
  • Purely cosmetic - Used only for display in the UI to differentiate “YOU” from other participants
Usernames are purely cosmetic. Authentication is handled entirely via the cookie token.

Access control flow

Here’s how authentication works when joining a room:
1

User visits /room/:roomId

Browser sends request to Next.js server.
2

Middleware checks for existing token

If the cookie exists and is valid for this room, the user is allowed in.
3

Middleware checks room capacity

If the room already has 2 participants, reject with “ROOM FULL” error.
4

New token generated

A unique token is created and stored in a cookie.
5

Token added to room

The token is appended to the room’s connected array in Redis.
6

User granted access

The request proceeds and the room page loads.

Token persistence

Tokens are stored in cookies, so they:
  • Persist across page refreshes
  • Survive browser restarts (until the room expires)
  • Are automatically sent with all API requests
  • Expire when the room expires (via Redis TTL)
Clearing your cookies will require you to rejoin the room (if capacity allows).

Security considerations

What this system protects against

  • Unauthorized access - Only participants with valid tokens can send/receive messages
  • Room overflow - The 2-participant limit is enforced server-side
  • Token theft - HttpOnly cookies prevent JavaScript access
  • CSRF - SameSite:strict prevents cross-origin requests

What this system does NOT protect against

  • Man-in-the-middle attacks - Use HTTPS in production
  • Shared computers - Cookies persist until cleared
  • Room ID guessing - Room IDs are random but not secret
This system prioritizes simplicity over military-grade security. For sensitive conversations, use end-to-end encrypted messaging apps.

Authentication errors

ErrorStatusCauseSolution
Missing Room ID or Token401Cookie missing or roomId not in queryRejoin the room URL
Invalid token401Token not in room’s participant listClear cookies and rejoin
Room full302Room already has 2 participantsWait or create a new room

Next steps

Room management

Learn how room access control works

Architecture overview

See how authentication fits into the overall system

Build docs developers (and LLMs) love