DEV Community

Ahmad Zunair
Ahmad Zunair

Posted on

I built a follow-gate SaaS with automatic access revocation

Most "gate your content behind a follow" tools stop at the gate. Someone follows, they get the link, and the product's job is done. Nobody checks what happens next. So I built the part that's missing: a daily job that re-checks every grant and revokes it the moment someone unfollows.

This isn't a promo post — I want to walk through the two pieces that actually make the mechanic work: the access ID generator and the revocation engine, both real code from the project.

The access ID
Every successful verification needs a token the audience can hold onto — something they can paste back in if they ever need to re-verify, and something collision-safe since it's the primary key audiences interact with. Format: FG-XXXXXX-XXXX, 12 uppercase alphanumeric characters, cryptographically random.

const ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
const SEGMENT_LENGTHS = [6, 4];
const MAX_ATTEMPTS = 10;

function randomSegment(length: number): string {
  const bytes = randomBytes(length);
  let out = "";
  for (let i = 0; i < length; i++) {
    out += ALPHABET[bytes[i]! % ALPHABET.length];
  }
  return out;
}

export async function generateAccessId(): Promise<string> {
  for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
    const token = formatToken();
    const existing = await prisma.accessId.findUnique({ where: { token } });
    if (!existing) return token;
  }
  throw new Error("Failed to generate a unique access ID after maximum attempts");
}
Enter fullscreen mode Exit fullscreen mode

Two things worth calling out. First, crypto.randomBytes rather than Math.random() — this token is a bearer credential, so it needs to be unguessable, not just unique. Second, the collision check hits the database directly instead of trusting the odds. The keyspace is huge, but "huge" isn't "zero," and a silent collision means two people sharing one grant. Cheap to check, so I check.

The revocation engine
This is the actual product. It runs daily (02:00 UTC, off-peak), pulls every access ID not re-verified in the last 20 hours, and re-checks the follow status per platform:

const staleAccessIds = await prisma.accessId.findMany({
  where: {
    status: { in: ["active", "warned"] },
    lastVerifiedAt: { lt: new Date(Date.now() - STALE_HOURS * 60 * 60 * 1000) },
    gate: { isActive: true },
  },
  include: { gate: { include: { creator: true } }, audienceMember: true },
});
Then, per access ID, batched 100 at a time with a 500ms gap between batches to stay polite to upstream APIs:

if (hadError) {
  actionTaken = "error";
} else if (stillFollowing) {
  actionTaken = "maintained";
  await prisma.accessId.update({
    where: { id: access.id },
    data: { status: "active", lastVerifiedAt: new Date(), warnedAt: null },
  });
} else if (access.status === "warned") {
  actionTaken = "revoked";
  await prisma.accessId.update({
    where: { id: access.id },
    data: { status: "revoked", revokedAt: new Date(), lastVerifiedAt: new Date() },
  });
  await sendRevocationConfirmEmail(access, access.gate);
} else {
  actionTaken = "warned";
  await prisma.accessId.update({
    where: { id: access.id },
    data: { status: "warned", warnedAt: new Date(), lastVerifiedAt: new Date() },
  });
  await sendRevocationWarningEmail(access, access.gate);
}
Enter fullscreen mode Exit fullscreen mode

The state machine is deliberately small: active → warned → revoked, with any successful re-check resetting straight to active. The part I went back and forth on is the hadError branch — if the platform API itself fails (rate limit, expired token, network blip), the engine does nothing to that access ID, just logs an error outcome. Punishing a follower for your API integration having a bad day is the fastest way to make this feature feel unfair. The mechanic only fires on a confirmed not_following, never on "we couldn't tell."

Every check — success, warning, revocation, or error — gets written to a verification_log row regardless of outcome. That audit trail is what makes "why did I lose access" a one-query answer instead of a support ticket.

FollowGate landing page hero showing the headline
FollowGate creator dashboard showing verified follower count, conversion rate, and a 30-day follower growth chart
FollowGate gate detail page showing visit stats, the revocation queue, and a follower table with active, warned, and revoked statuses
FollowGate gate builder step showing YouTube and Instagram checkboxes and the AND/OR follow logic switch

Where this ends up
AND/OR logic across platforms, a 24-hour warning window before the actual revoke, and the whole thing tested against a real Postgres instance rather than mocked end to end — that's the shape of the project. If you want the full source (Next.js 14, Prisma, the YouTube/Instagram/TikTok integration code, the Stripe billing, all of it): https://8678981945498.gumroad.com/l/weveo

Top comments (0)