<?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>Forem: Gábor Pintér</title>
    <description>The latest articles on Forem by Gábor Pintér (@gaborpinter).</description>
    <link>https://forem.com/gaborpinter</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.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F1562160%2F8549ba89-681c-42e0-a31d-88c949684bae.png</url>
      <title>Forem: Gábor Pintér</title>
      <link>https://forem.com/gaborpinter</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://forem.com/feed/gaborpinter"/>
    <language>en</language>
    <item>
      <title>Blog paywall with Astro, Cloudflare, Clerk and Stripe</title>
      <dc:creator>Gábor Pintér</dc:creator>
      <pubDate>Mon, 04 May 2026 11:48:29 +0000</pubDate>
      <link>https://forem.com/gaborpinter/blog-paywall-with-astro-cloudflare-clerk-and-stripe-1ihe</link>
      <guid>https://forem.com/gaborpinter/blog-paywall-with-astro-cloudflare-clerk-and-stripe-1ihe</guid>
      <description>&lt;p&gt;If you want to introduce a paywall to your Astro blog - &lt;a href="https://gaborpinter.com/sponsorship" rel="noopener noreferrer"&gt;like I do&lt;/a&gt; - then Cloudflare Pages, Clerk, and Stripe offer a pretty sweet setup.&lt;/p&gt;

&lt;p&gt;In this post I'll show you how you gate some of your content with subscriptions or one-time payments. We'll use Astro for our content, Cloudflare Pages for hosting, Clerk for authentication and Stripe for collecting payments.&lt;/p&gt;

&lt;h2&gt;
  
  
  Get started with Cloudflare Pages and Clerk
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Deploy Astro to &lt;a href="https://developers.cloudflare.com/pages/" rel="noopener noreferrer"&gt;Cloudflare Pages&lt;/a&gt;.&lt;/strong&gt; Cloudflare Pages supports SSR for Astro, which means we can evaluate requests on the server side. &lt;a href="https://developers.cloudflare.com/pages/configuration/git-integration/github-integration/" rel="noopener noreferrer"&gt;Deploy directly from GitHub&lt;/a&gt; for continuous integration.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Create a free &lt;a href="https://clerk.com/" rel="noopener noreferrer"&gt;Clerk&lt;/a&gt; account&lt;/strong&gt;. Clerk will handle creating and authenticating your users. Their &lt;a href="https://clerk.com/pricing" rel="noopener noreferrer"&gt;free tier&lt;/a&gt; is extremely generous.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Use &lt;a href="https://developers.cloudflare.com/pages/functions/middleware/" rel="noopener noreferrer"&gt;Cloudflare's middleware logic&lt;/a&gt;&lt;/strong&gt; to evaluate requests based on URL, e.g.: &lt;code&gt;/blog/exclusive-posts/*&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;In your &lt;code&gt;middleware.ts&lt;/code&gt;, use Clerk to validate user authentication.&lt;/strong&gt; Redirect to a paywall when the user is not logged in.
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// middleware.ts (simplified)&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;defineMiddleware&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;astro:middleware&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;createClerkClient&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@clerk/backend&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;onRequest&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;defineMiddleware&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;next&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;url&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;pathname&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pathname&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="c1"&gt;// Only gate the exclusive-posts pages.&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;pathname&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startsWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;EXCLUSIVE_POSTS_PREFIX&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;next&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;secretKey&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;getClerkSecretKeyFromEnvironment&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;publishableKey&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;getClerkPublishableKeyFromEnvironment&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;clerkClient&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createClerkClient&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;secretKey&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

    &lt;span class="c1"&gt;// Get auth state&lt;/span&gt;
    &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;authState&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nx"&gt;authState&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;clerkClient&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;authenticateRequest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nx"&gt;publishableKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="nx"&gt;secretKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="na"&gt;acceptsToken&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;session_token&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;302&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;Location&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;paywallRedirectLocation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pathname&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="c1"&gt;// Get userId&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;userId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;getUserIdFromSessionClaims&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;authState&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;302&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;Location&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;paywallRedirectLocation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pathname&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="c1"&gt;// User is logged in, continue&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;next&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Signing in is only part of the story: for a real paywall you also need to &lt;strong&gt;check Clerk user metadata&lt;/strong&gt; (or equivalent claims) so an active subscription or valid one-time purchase grants access, and redirect everyone else, including logged-in users without entitlement, to your paywall.&lt;/p&gt;

&lt;p&gt;At this point you can test this setup manually, without integrating Stripe. Create users manually in the Clerk Dashboard, log in with them and try to access content on your protected URLs. You can even create separate development and production environments in Clerk.&lt;/p&gt;

&lt;p&gt;Once you are ready, you can move on to the next phase, which is Stripe integration.&lt;/p&gt;

&lt;h2&gt;
  
  
  Stripe integration
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Create &lt;a href="https://stripe.com/en-hu/payments/payment-links" rel="noopener noreferrer"&gt;Stripe Payment Links&lt;/a&gt;.&lt;/strong&gt; Payment Links give you pretty sweet customization options, like mandatory billing address fields and custom fields for newsletter subscriptions, for example. I've created a monthly and a yearly subscription, and one for one-time payments too.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Create a &lt;a href="https://workers.cloudflare.com/" rel="noopener noreferrer"&gt;Cloudflare Worker&lt;/a&gt;&lt;/strong&gt; that will get called by a Stripe webhook. Place this worker within your Astro repo, so you can deploy it together with the site. The task of this worker is to handle Stripe event notifications and react to them: create a new user in Clerk if payment has been processed and no user is found for the given email address, or update user metadata when subscription changes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Create a &lt;a href="https://docs.stripe.com/api/webhook_endpoints" rel="noopener noreferrer"&gt;Stripe webhook&lt;/a&gt;&lt;/strong&gt; in the &lt;a href="https://dashboard.stripe.com/" rel="noopener noreferrer"&gt;Stripe Dashboard&lt;/a&gt;. Provide your Cloudflare Worker URL as the endpoint. Make sure to include the following events:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;checkout.session.completed
customer.subscription.created
customer.subscription.updated
customer.subscription.deleted
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Webhook signing and secrets
&lt;/h3&gt;

&lt;p&gt;Stripe signs each webhook request with an HMAC of the raw body and a timestamp, and sends the result in the &lt;a href="https://docs.stripe.com/webhooks/signatures" rel="noopener noreferrer"&gt;&lt;code&gt;Stripe-Signature&lt;/code&gt;&lt;/a&gt; header. Your Worker should read the body as &lt;strong&gt;text&lt;/strong&gt; (not parsed JSON first), verify the signature with your endpoint’s &lt;strong&gt;signing secret&lt;/strong&gt;, and only then &lt;code&gt;JSON.parse&lt;/code&gt; the payload. If verification fails, respond with &lt;code&gt;400&lt;/code&gt; and do not update Clerk.&lt;/p&gt;

&lt;p&gt;When you add the endpoint in the Stripe Dashboard, Stripe shows a &lt;strong&gt;Signing secret&lt;/strong&gt; (it starts with &lt;code&gt;whsec_&lt;/code&gt;). Treat it like a password: store it only in Cloudflare, not in git.&lt;/p&gt;

&lt;p&gt;For a Worker in its own folder (for example &lt;code&gt;workers/stripe-webhook/&lt;/code&gt;), define the bindings your code expects (&lt;code&gt;STRIPE_WEBHOOK_SECRET&lt;/code&gt;, &lt;code&gt;CLERK_SECRET_KEY&lt;/code&gt;) and set the values with Wrangler:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# from the worker directory; use your Worker name / env flags as in wrangler.toml&lt;/span&gt;
npx wrangler secret put STRIPE_WEBHOOK_SECRET &lt;span class="nt"&gt;--config&lt;/span&gt; wrangler.toml &lt;span class="nt"&gt;--env&lt;/span&gt; prod
npx wrangler secret put CLERK_SECRET_KEY &lt;span class="nt"&gt;--config&lt;/span&gt; wrangler.toml &lt;span class="nt"&gt;--env&lt;/span&gt; prod
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Wrangler prompts you to paste each value; it stores them encrypted on Cloudflare’s side. Repeat with &lt;code&gt;--env dev&lt;/code&gt; for a development Worker and use a separate &lt;a href="https://docs.stripe.com/webhooks#test-webhook" rel="noopener noreferrer"&gt;Stripe webhook endpoint&lt;/a&gt; (or the &lt;a href="https://docs.stripe.com/stripe-cli" rel="noopener noreferrer"&gt;Stripe CLI&lt;/a&gt; forwarding URL) that points at that URL, with its own signing secret.&lt;/p&gt;

&lt;p&gt;In my own setup, I also use three metadata fields on Clerk users that store information about their subscription status.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;hasOngoingSubscription: boolean
ongoingAccessUntil: Date
oneTimeAccessUntil: Date
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;hasOngoingSubscription&lt;/code&gt; becomes &lt;code&gt;true&lt;/code&gt; when they subscribe and switches to &lt;code&gt;false&lt;/code&gt; when they cancel. Upon cancellation the &lt;code&gt;ongoingAccessUntil&lt;/code&gt; field gets filled with a date. When someone performs a one-time purchase, their &lt;code&gt;oneTimeAccessUntil&lt;/code&gt; field gets filled with a date.&lt;/p&gt;

&lt;p&gt;As a bonus, you can create a static &lt;a href="https://gaborpinter.com/successful-payment" rel="noopener noreferrer"&gt;Thank You page&lt;/a&gt; in your Astro blog. Use &lt;a href="https://confettijs.org/" rel="noopener noreferrer"&gt;confetti.js&lt;/a&gt; for a ceremonial feel. Use the URL of this page as the redirect URL in your Stripe Payment Links, so your users land on a nice page after successful payments that gives them instructions about the next steps.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;This post has been originally published on my &lt;a href="https://gaborpinter.com/blog/posts/blog-paywall-with-astro-cloudflare-clerk-and-stripe/" rel="noopener noreferrer"&gt;blog&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>astro</category>
      <category>clerk</category>
      <category>stripe</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
