hectoday
DocsCoursesChangelog GitHub
DocsCoursesChangelog GitHub

Access Required

Enter your access code to view courses.

Invalid code

← All courses Authorization with @hectoday/http

Beyond Authentication

  • Authentication vs. Authorization
  • Project Setup

Role-Based Access Control (RBAC)

  • Roles and What They Mean
  • Checking Roles in Route Handlers
  • Role Hierarchy

Permission-Based Access Control

  • From Roles to Permissions
  • Checking Permissions
  • Custom Permissions

Organization Scoping

  • Multi-Tenancy
  • Switching Organizations
  • Inviting Members

API Keys and Scoping

  • API Keys
  • Scoped API Keys

Putting It All Together

  • Policy Functions
  • Audit Logging
  • Authorization Checklist
  • Capstone: Multi-Tenant Notes API

Inviting Members

Growing a team

So far, memberships are created via seed data. A real app needs an invite flow: an owner invites a person by email, that person accepts, and a membership is created with the assigned role.

The invites table

CREATE TABLE IF NOT EXISTS invites (
  id TEXT PRIMARY KEY,
  org_id TEXT NOT NULL,
  email TEXT NOT NULL,
  role TEXT NOT NULL DEFAULT 'viewer',
  invited_by TEXT NOT NULL,
  created_at TEXT NOT NULL DEFAULT (datetime('now')),
  FOREIGN KEY (org_id) REFERENCES organizations(id),
  FOREIGN KEY (invited_by) REFERENCES users(id),
  UNIQUE(org_id, email)
);

Add this to src/db.ts. The UNIQUE(org_id, email) constraint prevents duplicate invites to the same person in the same org.

Creating an invite

// In src/routes/orgs.ts
route.post("/orgs/:orgId/invites", {
  resolve: async (c) => {
    const user = authenticate(c.request);
    if (user instanceof Response) return user;

    const perm = requirePermission(user, c.params.orgId, "members:invite");
    if (perm instanceof Response) return perm;

    const body = await c.request.json();
    const { email, role } = body;

    if (!email || !role) {
      return Response.json({ error: "Email and role required" }, { status: 400 });
    }

    // Prevent inviting someone who is already a member
    const existingUser = db.prepare("SELECT id FROM users WHERE email = ?").get(email) as any;
    if (existingUser) {
      const existingMembership = getMembership(existingUser.id, c.params.orgId);
      if (existingMembership) {
        return Response.json({ error: "User is already a member" }, { status: 409 });
      }
    }

    // Prevent an editor from inviting someone as owner
    const inviterMembership = getMembership(user.id, c.params.orgId);
    if (!inviterMembership) {
      return Response.json({ error: "Not found" }, { status: 404 });
    }

    const ROLE_LEVELS: Record<string, number> = { viewer: 1, editor: 2, owner: 3 };
    if ((ROLE_LEVELS[role] ?? 0) > (ROLE_LEVELS[inviterMembership.role] ?? 0)) {
      return Response.json(
        { error: "Cannot invite someone with a higher role than your own" },
        { status: 403 },
      );
    }

    const id = crypto.randomUUID();
    try {
      db.prepare("INSERT INTO invites (id, org_id, email, role, invited_by) VALUES (?, ?, ?, ?, ?)")
        .run(id, c.params.orgId, email, role, user.id);
    } catch (err: any) {
      if (err.message?.includes("UNIQUE")) {
        return Response.json({ error: "Invite already sent" }, { status: 409 });
      }
      throw err;
    }

    // In production, send an email with an invite link
    console.log(`\n📧 Invite for ${email} to org ${c.params.orgId} as ${role}\n`);

    return Response.json({ id, email, role }, { status: 201 });
  },
}),

Key security checks:

Permission check: Only users with members:invite (owners) can create invites.

Role escalation prevention: An editor cannot invite someone as an owner. The invited role must be at or below the inviter’s role. This prevents privilege escalation.

Duplicate prevention: The UNIQUE constraint prevents duplicate invites. We also check for existing memberships.

Accepting an invite

The invited person logs in and accepts:

route.post("/invites/:inviteId/accept", {
  resolve: (c) => {
    const user = authenticate(c.request);
    if (user instanceof Response) return user;

    const invite = db.prepare("SELECT * FROM invites WHERE id = ?").get(c.params.inviteId) as any;
    if (!invite) return Response.json({ error: "Invite not found" }, { status: 404 });

    // Verify the invite is for this user's email
    if (invite.email !== user.email) {
      return Response.json({ error: "This invite is not for you" }, { status: 403 });
    }

    // Check if already a member
    const existing = getMembership(user.id, invite.org_id);
    if (existing) {
      // Already a member — delete the invite and return
      db.prepare("DELETE FROM invites WHERE id = ?").run(c.params.inviteId);
      return Response.json({ error: "Already a member" }, { status: 409 });
    }

    // Create the membership
    const memId = crypto.randomUUID();
    db.prepare("INSERT INTO memberships (id, user_id, org_id, role) VALUES (?, ?, ?, ?)")
      .run(memId, user.id, invite.org_id, invite.role);

    // Delete the invite (single-use)
    db.prepare("DELETE FROM invites WHERE id = ?").run(c.params.inviteId);

    return Response.json({
      message: "Invite accepted",
      orgId: invite.org_id,
      role: invite.role,
    });
  },
}),

The acceptance flow:

  1. Look up the invite by ID
  2. Verify the invite email matches the logged-in user’s email
  3. Create a membership with the invited role
  4. Delete the invite (single-use)

The email check is critical: it prevents user A from accepting an invite meant for user B.

Exercises

Exercise 1: As Alice (owner of Acme), invite [email protected] as a viewer. Create a user account for Dave, log in, and accept the invite. Verify Dave now has a membership in Acme.

Exercise 2: As Bob (editor at Acme), try to invite someone as an owner. It should fail with 403 because Bob’s role is lower than owner.

Exercise 3: Try accepting an invite twice. The second attempt should fail because the invite was deleted after the first acceptance.

Exercise 4: Try accepting an invite meant for a different email. It should fail with 403.

Why do we prevent an editor from inviting someone as an owner?

← Switching Organizations API Keys →

© 2026 hectoday. All rights reserved.