Access Control

Invite links

Let users share a URL that grants another user access to a private resource without knowing their identity in advance.

Invite links are a pattern that let a resource owner share a URL that grants access to anyone who has it without knowing the recipient's identity in advance. The URL carries the resource ID plus a secret that the owner stores in a private invite row.

When an invitee opens the URL, your backend validates the code and inserts a membership row for their already-authenticated session. From that point on, access flows through the normal membership rule.

This recipe deliberately keeps redemption on a backend. A link is a bearer capability: it must be checked at the same authority that performs the membership write. Do not model the code as a client-supplied session claim or authorize the write from a one-off read; that would let the read and write run under different credentials.

Schema

Keep invite codes in their own table, not on the resource row. Jazz permissions are row-level, so a joinCode on the chat would be visible to every chat member. This table has no client read rule; only the backend checks its code. The members table records which invite each user used to join, so you can audit or revoke individual memberships later.

schema.ts
const schema = {
  chats: s.table(
    {},
    {
      members: s.reverse("chatMembers", "chat"),
      invites: s.reverse("chatInvites", "chat"),
    },
  ),
  chatMembers: s.table(
    {
      chatId: s.uuid(),
      user_id: s.uuid(),
      inviteId: s.string().optional(),
    },
    { chat: s.rel("chats", "chatId") },
  ),
  chatInvites: s.table(
    {
      chatId: s.uuid(),
      code: s.string(),
      singleUse: s.boolean(),
    },
    { chat: s.rel("chats", "chatId") },
  ),
};

Permissions

A user can read a chat once they have a membership row. A chat creator can bootstrap their own membership and create or revoke invite links, but no client can write a membership for somebody else. The only way for an invitee to join is the server route below. The server has a backend identity and inserts the row on their behalf after validating the join code.

permissions.ts
s.definePermissions(app, ({ policy, allOf, anyOf, session }) => {
  policy.chats.allowRead.where((chat) =>
    policy.chatMembers.exists.where({ chatId: chat.id, user_id: session.user.account }),
  );
  policy.chats.allowInsert.always();

  // Users can read their own membership row; chat creators can read every
  // member of their chats.
  policy.chatMembers.allowRead.where((member) =>
    anyOf([
      { user_id: session.user.account },
      policy.chats.exists.where({ id: member.chatId, "$createdBy.account": session.user.account }),
    ]),
  );

  // The creator can insert their own membership in their own chat. Everyone
  // else must come through the server route, which writes with backend
  // privileges.
  policy.chatMembers.allowInsert.where((member) =>
    allOf([
      { user_id: session.user.account },
      policy.chats.exists.where({ id: member.chatId, "$createdBy.account": session.user.account }),
    ]),
  );

  // Users can leave; chat creators can remove any member.
  policy.chatMembers.allowDelete.where((member) =>
    anyOf([
      { user_id: session.user.account },
      policy.chats.exists.where({ id: member.chatId, "$createdBy.account": session.user.account }),
    ]),
  );

  // Invite codes are bearer capabilities. They never sync back down to a client.
  policy.chatInvites.allowRead.never();
  policy.chatInvites.allowInsert.where((invite) =>
    policy.chats.exists.where({ id: invite.chatId, "$createdBy.account": session.user.account }),
  );
  policy.chatInvites.allowDelete.where((invite) =>
    policy.chats.exists.where({ id: invite.chatId, "$createdBy.account": session.user.account }),
  );
});

See Permissions for more on exists.where, allOf, and anyOf, and Session identity and authorship for how the backend stamps the user as the row's author while keeping backend permissions.

The creator inserts the chat, their own membership, and a private invite row client-side. These writes are allowed by the permission rules above — the chat row is open to insert, and the membership and invite rules let the creator bootstrap resources that match their $createdBy. The code goes only in the invite row and URL.

createInviteLink.ts
export function createInviteLink(
  db: ReturnType<typeof useDb>,
  accountId: string,
  { singleUse = false }: { singleUse?: boolean } = {},
): string {
  const joinCode = crypto.randomUUID();

  const { value: chat } = db.insert(app.chats, {});

  db.insert(app.chatMembers, { chatId: chat.id, user_id: accountId });
  db.insert(app.chatInvites, { chatId: chat.id, code: joinCode, singleUse });

  return `${window.location.origin}/#/invite/${chat.id}/${joinCode}`;
}

The code lives in the URL fragment (after #) to keep it out of server access logs, CDN logs, and Referer headers. Don't pass the code as a route parameter or query string — those land in server logs.

Accepting an invite

The invitee opens the link while signed in. The client posts the chat ID and code to a server route. The server uses one exclusive transaction to read the private invite, check whether the user is already a member, insert their membership, and consume a single-use invite. The route is idempotent — it skips the insert if the user already has a membership, so re-opening the link or retrying after a transient error won't create duplicate rows.

Server route

The route reads and writes as the backend: the invitee has no read access to the chat until they're a member, and the chatMembers.allowInsert rule only admits the chat's creator, so they can't insert their own membership directly. The ready backend client verifies the bearer with await client.withAttributionForRequest(request), which keeps backend permissions while stamping the membership's edit metadata to the verified caller. An opaque admitted account handle can instead be passed to await client.withAttribution(account).

The transaction is what makes redemption correct under concurrency. A multi-use invite stays in the table, so it is deliberately replayable by anyone holding the link. A single-use invite is deleted in the same exclusive transaction as the membership insert: two simultaneous redeems cannot both succeed. If the authority rejects the transaction because another redemption won the race, report that failure to the client and let it retry only if that remains appropriate for your UI.

The client referenced below is the ready backend Jazz client for your app — see Backend context setup for how to create one.

api/invite/redeem.ts
export async function POST(req: Request): Promise<Response> {
  const requester = await client.forRequest(req);
  const user = requester.getAuthState().session?.user.account;
  if (!user) return new Response("Account required", { status: 401 });

  const { chatId, code } = (await req.json()) as { chatId: string; code: string };

  // Preserve the verified caller as author while using backend permissions.
  const backendDb = await client.withAttributionForRequest(req);
  const result = await backendDb.exclusiveTransaction(async (tx) => {
    // Checking membership first keeps re-opening a successfully redeemed link idempotent,
    // even after a single-use invite has been consumed.
    const existing = await tx.one(app.chatMembers.where({ chatId, user_id: user }));
    if (existing) return "already-member" as const;

    const invite = await tx.one(app.chatInvites.where({ chatId, code }));
    if (!invite) return "invalid" as const;

    tx.insert(app.chatMembers, { chatId, user_id: user, inviteId: invite.id });
    if (invite.singleUse) tx.delete(app.chatInvites, invite.id);
    return "joined" as const;
  });

  // Exclusive transactions settle at the authority, so wait() takes no tier.
  await result.wait();
  if (result.value === "invalid") return new Response("invalid invite", { status: 400 });

  return Response.json({ ok: true });
}

Client handler

Route /#/invite/:chatId/:code to a component that calls the server route and then redirects.

InviteHandler.tsx
export function InviteHandler({ chatId, code }: { chatId: string; code: string }) {
  const handled = useRef(false);

  useEffect(() => {
    if (handled.current) return;
    handled.current = true;

    fetch("/api/invite/redeem", {
      method: "POST",
      headers: { "content-type": "application/json" },
      body: JSON.stringify({ chatId, code }),
    })
      .then((res) => {
        if (!res.ok) throw new Error(`redeem failed: ${res.status}`);
        navigate(`/chat/${chatId}`);
      })
      .catch((err) => {
        console.error("failed to join", err);
        handled.current = false;
      });
  }, [chatId, code]);

  return <p>Joining…</p>;
}

Once the route returns, the user's subscription picks the chat up via the membership rule, and access persists for as long as the membership row exists.

Revoking access

Delete a member's row to revoke their access immediately. The server stops syncing the resource to them as soon as the row is gone.

revokeMember.ts
export function revokeMember(db: ReturnType<typeof useDb>, memberId: string) {
  db.delete(app.chatMembers, memberId);
}

An invite link is a bearer capability: deleting a membership alone does not stop somebody who kept the link from redeeming it again. Delete or rotate the private invite row too when you need to invalidate the link for future redeemers.

On this page