Anonymous Users via Sessions
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 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 sessionId
s and userId
s 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.
Convex is the sync platform with everything you need to build your full-stack project. Cloud functions, a database, file storage, scheduling, search, and realtime updates fit together seamlessly.