<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Ahmad Zunair</title>
    <description>The latest articles on DEV Community by Ahmad Zunair (@ahmad_zunair_e02b7f71f169).</description>
    <link>https://dev.to/ahmad_zunair_e02b7f71f169</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4149892%2Fae49ed36-d441-49c1-ac14-ff513b468031.jpg</url>
      <title>DEV Community: Ahmad Zunair</title>
      <link>https://dev.to/ahmad_zunair_e02b7f71f169</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/ahmad_zunair_e02b7f71f169"/>
    <language>en</language>
    <item>
      <title>I built a follow-gate SaaS with automatic access revocation</title>
      <dc:creator>Ahmad Zunair</dc:creator>
      <pubDate>Tue, 29 Sep 2026 14:04:26 +0000</pubDate>
      <link>https://dev.to/ahmad_zunair_e02b7f71f169/i-built-a-follow-gate-saas-with-automatic-access-revocation-4l8b</link>
      <guid>https://dev.to/ahmad_zunair_e02b7f71f169/i-built-a-follow-gate-saas-with-automatic-access-revocation-4l8b</guid>
      <description>&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The access ID&lt;/strong&gt;&lt;br&gt;
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.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;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 &amp;lt; length; i++) {
    out += ALPHABET[bytes[i]! % ALPHABET.length];
  }
  return out;
}

export async function generateAccessId(): Promise&amp;lt;string&amp;gt; {
  for (let attempt = 0; attempt &amp;lt; 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");
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The revocation engine&lt;/strong&gt;&lt;br&gt;
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:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;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);
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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."&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fc2wdw9at8nuz512tg7oo.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fc2wdw9at8nuz512tg7oo.png" alt="FollowGate landing page hero showing the headline " width="800" height="500"&gt;&lt;/a&gt;&lt;br&gt;
&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7tdiel0uokfyvpqakjr6.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7tdiel0uokfyvpqakjr6.png" alt="FollowGate creator dashboard showing verified follower count, conversion rate, and a 30-day follower growth chart" width="800" height="500"&gt;&lt;/a&gt;&lt;br&gt;
&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fiyyj4qxm5emerlru5dnj.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fiyyj4qxm5emerlru5dnj.png" alt="FollowGate gate detail page showing visit stats, the revocation queue, and a follower table with active, warned, and revoked statuses" width="800" height="500"&gt;&lt;/a&gt;&lt;br&gt;
&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fn05vyderllazlokjevio.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fn05vyderllazlokjevio.png" alt="FollowGate gate builder step showing YouTube and Instagram checkboxes and the AND/OR follow logic switch" width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Where this ends up&lt;/strong&gt;&lt;br&gt;
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): &lt;a href="https://8678981945498.gumroad.com/l/weveo" rel="noopener noreferrer"&gt;https://8678981945498.gumroad.com/l/weveo&lt;/a&gt;&lt;/p&gt;

</description>
      <category>backend</category>
      <category>buildinpublic</category>
      <category>saas</category>
      <category>softwaredevelopment</category>
    </item>
  </channel>
</rss>
