Bright ideas and techniques for building with Convex.
Profile image
Ian Macartney
a year ago

Anonymous Users via Sessions

Friends don't make friends log in

When building a new app, it can be challenging to build much behavior without the notion of a user. However, getting users to sign up for a new service before seeing any benefits is challenging. Signing up is cumbersome, and many people don’t want to share their personal information with a company until they want a long-standing relationship. In this post, we’ll discuss how to model ephemeral users, leveraging the session helpers duscussed in this post. We’ll also discuss the challenges with anonymous auth.

As a real example, while building a multiplayer Dall-E-based game, one goal is to be able to play with the game without having to log in first. However, we also want users to be able to log in eventually, use their profile picture, access old games, or log back into games on a different device or browser tab.

Instead of passing session IDs around for the game, we create a user when the session is created and use that user ID. This way, we can pass around user IDs (which, in general, shouldn’t be treated as secrets) and still have a persistent shared identifier between the client and the server.

Storing anonymous users in the “users” table

To illustrate the session middleware, I made a demo app in our convex-demos repo.

The app is a clone of our tutorial demo, which is a basic chat app. The tutorial generates a random user name on the client when the page loads and sends that string whenever it sends a message. The basic demo has these two downsides that sessions fixes:

  • Refreshing the page changes your user name.
  • An updated name isn’t reflected in past messages.

Note: Below we'll discuss where ctx.user and ctx.sessionId come from.

Persisting user name:

We can keep your name constant using sessions by storing it a users table, which has a sessionId field we can look it up by. Instead of initializing the random user name in the client, we write it to the users table along with the associated sessionsId when updating the name:

export const set = mutationWithSession({
  args: { name: v.string() },
  handler: async (ctx, { name }) => {
    if (ctx.user) {
      await ctx.db.patch(ctx.user._id, { name });
    } else {
      await ctx.db.insert("users", { name, sessionId: ctx.sessionId });
    }
  },
});

This way, when the user reloads the page, it will read the existing session ID from the browser (in localStorage or sessionStorage, whichever you configured) and use the existing session name.

Using anonymous users relationally:

In the users-and-auth demo, we solve updating names by associating each message with a userId instead of the user name string. When listing the messages, it would look up the user name on the fly, so the messages always reflected the latest name. The session demo's approach is similar, in that it associates a userId with a message. It finds or creates the userId based on the current sessionId. This means updates to the user name will be reflected in old messages:

Chat app in the sessions demoChat app in the sessions demo

To send a message, we can get or create a user as follows:

// in convex/messages.ts
export const send = mutationWithSession({
  args: { body: v.string() },
  handler: async (ctx, { body }) => {
    let userId = ctx.user?._id;
    if (!userId) {
      const { sessionId } = ctx;
      userId = await ctx.db.insert("users", { name: "Anonymous", sessionId });
    }
    await ctx.db.insert("messages", { body, author: userId });
  },
});

Using custom functions for ctx.user and ctx.sessionId

You might have noticed a nice ctx.user and ctx.sessionId magically appearing for these mutationWithSession and queryWithSession functions. Those are defined in convex/lib/sessions.ts using the custom functions introduced in this post.

They let you do things like:

async function getUser(ctx: QueryCtx, sessionId: SessionId) {
  const user = await ctx.db
    .query("users")
    .withIndex("by_sessionId", (q) => q.eq("sessionId", sessionId))
    .unique();
  return user;
}

export const mutationWithSession = customMutation(mutation, {
  args: SessionIdArg,
  input: async (ctx, { sessionId }) => {
    const user = await getUser(ctx, sessionId);
    return { ctx: { ...ctx, user, sessionId }, args: {} };
  },
});

Then anyone defining a mutationWithSession will have the ctx also include the user and sessionId. The useSessionMutation react hook will automatically pass up the sessionId. See this post for more details on how that works.

Tips & Gotchas

If you want a very lightweight solution, it doesn’t get much simpler than this. However, with its simplicity comes a limitation of what it can represent.

If your app will have logged-in users who will interact with anonymous users, it is awkward to have to look in two different places for users and store two different kinds of IDs depending on which type they are. You might consider having a single "users" table and an isAnonymous boolean field.

If your app can have multiple sessions per user you'll want to have a separate table that keeps track of sessionIds and userIds as a many-to-many table, instead of storing it in the users table directly.

If you want to keep session IDs private you should avoid passing around session IDs as user identifiers. The session ID is essentially the user's credential. So if anyone else has their ID, their requests can impersonate the user. Check out built-in auth. Generally don't return the sessionId associated with other users.

Summary

In this post, we looked at a couple of strategies for managing user information without requiring a login. Follow along with the multiplayer game using OpenAI here.

Build in minutes, scale forever.

Convex is the backend application platform with everything you need to build your project. Cloud functions, a database, file storage, scheduling, search, and realtime updates fit together seamlessly.

Get started