I’ve built a paywall system for this blog. It was quite an endeavor to make a home-grown solution that works with my static Jekyll site, but I think I got it.
From now on, I’ll be adding bonus content to posts that will only available to subscribers. You can subscribe at any level — the benefits are the same whether you pay $3, $6, or $12 per month. You can subscribe using PayPal, with or without a PayPal account. I’m going to be phasing out Memberful as I’m able to switch over the subscribers there. So become a subscriber today and don’t miss out on anything.
All of my current supporters should automatically have been ported to the new system, and should be able to log in just by providing the email address with which they used to subscribe. (If you’re currently using Memberful, please consider cancelling there and re-subscribing via the links on the support page.)
As a benefit that I hope will make subscribing worthwhile, I’ve negotiated hundreds of dollars in savings from my favorite software developers, with 10-50% discounts about 40 apps and services (and growing), including discounts on books and educational screencasts.
I’m also going to implement a paid tier on the forum, but I’m not planning to not converse with non-paying subscribers, so I’ll have to figure out what the benefit there will be.
I have no intention of paywalling my best content, or limit access to any of my projects. Paywalled content will be bonus content within publicly-available posts. It’s really still just a way to support my work, so if you appreciate what I do and want to see more of it, please do consider subscribing.
As a demonstration, I’m going to detail how I built this system as bonus content. So if you want to see that, subscribe.
Be sure to check out the Member Discounts page to see all of the bonuses you can get.
[paywall “My first paywalled content — subscribe to see how it all works!”]
How It Works
I built a custom paywall system for my Jekyll-based website that protects premium content while providing a seamless user experience. Instead of traditional username/password authentication, I implemented a magic link system that sends one-time login links via email. Here’s how it works and why I built it this way.
The Challenge
I wanted to offer premium content to my subscribers while meeting several requirements:
- Secure content protection – Content should not be accessible by viewing page source
- Seamless UX – No complex login flows or password management
- Multi-provider support – Handle subscribers from different platforms (Memberful, PayPal, etc.)
- Static site compatibility – Work with Jekyll’s static generation
- Email-based verification – Prevent unauthorized access by validating email ownership
Architecture Overview
The system consists of four main components:
1. Jekyll Plugin (paywall_tag.rb)
There’s a custom Liquid tag that (paywall) wraps premium content during site generation. During the build process, this plugin:
- Extracts the premium content
- Saves it as JSON files outside the webroot (server-side protection)
- Generates a placeholder with a login UI
- Registers each paywall instance for client-side management
2. Membership Service (Sinatra API)
A Ruby/Sinatra backend (membership_service.rb) that handles:
- Member verification via SQLite database
- Webhook integration with subscription platforms (Memberful, PayPal)
- Magic link generation and email delivery (via Mailgun)
- Access token management (90-day cookies)
- Protected content delivery
3. Client-Side JavaScript Module
A revealing module pattern (bt.Paywall) that:
- Detects authentication state on page load
- Manages login modal UI
- Fetches and displays protected content
- Handles cross-domain authentication (production vs localhost)
- Provides logout functionality
4. Member Database
An SQLite database tracking:
- Active subscriptions from multiple sources
- Access tokens and expiration
- Login tokens (magic links)
- Webhook sync status
How It Works: User Perspective
First Visit
- User lands on a page with premium content
- Sees a paywall overlay with two options:
- Subscribe Now (links to subscription page)
- I’m a Subscriber (login button)
Authentication Flow
- User clicks “I’m a Subscriber”
- Modal appears requesting their email address
- Email is sent with a magic link (valid for 15 minutes)
- User clicks the link in their email
- System verifies:
- Token is valid and not expired
- Token hasn’t been used
- Email matches an active subscriber
- Access token cookie is set (90-day expiration)
- User is redirected back to the page
- All paywalled content unlocks automatically
- Success notification confirms login
Subsequent Visits
- Cookie is detected automatically
- Content unlocks on page load
- No interaction required
Technical Implementation
Server-Side Content Protection
The key security feature is that premium content never reaches the browser unless authenticated:
“`ruby
# During Jekyll build – Save content to disk
def save_protected_content(content, content_id)
output_dir = File.join(@context.registers[:site].dest, ‘_paywall_content’)
FileUtils.mkdir_p(output_dir)
content_file = File.join(output_dir, “#{content_id}.json”)
File.write(content_file, { html: content }.to_json)
end
“`
The _paywall_content directory lives outside the webroot and is served only by the API after authentication.
Magic Link Authentication
Instead of passwords, users receive one-time login links:
“`ruby
post ‘/request-login’ do
# 1. Verify email matches an active subscriber
member = find_member(email)
halt 403 unless member && member[‘status’] == ‘active’
# 2. Generate secure token
token = SecureRandom.urlsafe_base64(32)
# 3. Store with 15-minute expiration
db.execute(«-SQL, [email, token, Time.now.utc + (15 * 60)])
INSERT INTO login_tokens (email, token, expires_at, ip_address, return_to)
VALUES (?, ?, ?, ?, ?)
SQL
# 4. Send via Mailgun
send_magic_link_email(email, token, return_to_url)
end
“`
Token Verification
When the user clicks the magic link:
“`ruby
get ‘/auth/:token’ do
# Verify token is valid, not expired, and not used
login_data = verify_login_token(params[:token])
halt 403 unless login_data
# Confirm active membership
member = find_member(login_data[‘email’])
halt 403 unless member && member[‘status’] == ‘active’
# Generate 90-day access token
access_token = generate_access_token(member[‘id’])
# Mark login token as used (one-time use)
mark_login_token_used(params[:token])
# Set cookie and redirect
response.set_cookie(‘bt_member_token’, {
value: access_token,
expires: Time.now + (90 * 24 * 60 * 60),
httponly: false,
same_site: :lax
})
redirect login_data[‘return_to’]
end
“`
JavaScript picks up the token from the URL and sets it client-side:
“`javascript
function checkUrlForToken() {
var urlParams = new URLSearchParams(window.location.search);
var token = urlParams.get(‘token’);
if (token) {
// Set cookie on localhost domain
setCookie(cookieName, token, 90);
// Clean URL (remove token)
urlParams.delete('token');
history.replaceState({}, document.title,
window.location.pathname + '?' + urlParams.toString()
); } } ```
Email Client Prefetch Protection
Modern email clients (Gmail, Apple Mail) prefetch links for security scanning, causing tokens to be marked as “used” before the user actually clicks them. I added a 30-second grace period:
“`ruby
# Check if token was recently used by same IP
def check_recent_token_use(token, ip_address)
db.execute(«-SQL, [token, ip_address])
SELECT * FROM login_tokens
WHERE token = ?
AND ip_address = ?
AND used_at IS NOT NULL
AND datetime(used_at) > datetime(‘now’, ‘-30 seconds’)
LIMIT 1
SQL
end
In auth endpoint
if token_invalid && recently_used_by_same_ip
# Allow through – likely email prefetch
redirect return_to_url
end
“`
Security Considerations
Why Magic Links?
- No password storage – Eliminates password breach risks
- Email verification – Confirms the user owns the email account
- Time-limited – 15-minute expiration window
- One-time use – Tokens can’t be reused
- IP tracking – Helps detect suspicious activity
Content Protection
Premium content is never in the HTML source:
- Saved to disk during Jekyll build
- Stored outside webroot in
_paywall_content/ - Only served via API endpoint with valid token
- Fetched client-side after authentication
This prevents “view source” attacks or web scraping of premium content.
Access Tokens
- 90-day cookie expiration (balance of security and convenience)
httponly: falseallows JavaScript access for content fetchingsame_site: laxprevents CSRF attacks- Validated on every content request
Webhook Integration
The system stays in sync with subscription platforms via webhooks:
“`ruby
post ‘/webhook/memberful’ do
# Verify signature
halt 403 unless verify_memberful_signature(request.body.read, request.env[‘HTTP_X_MEMBERFUL_SIGNATURE’])
case payload[‘event’]
when ‘member.created’, ‘member.updated’
sync_member(payload[‘member’])
when ‘subscription.activated’
update_member_status(payload[‘member_id’], ‘active’)
when ‘subscription.deactivated’
update_member_status(payload[‘member_id’], ‘inactive’)
end
end
“`
Developer Experience
Testing Locally
The system works seamlessly in development:
“`bash
# Start Jekyll
rake serve
Membership service runs as systemd service
systemctl –user status paywall
“`
Testing commands:
“`javascript
// Log in (opens modal)
bt.Paywall.reveal()
// Check current state
document.cookie
// Log out
bt.Paywall.logout()
“`
Deployment
Simple rsync deploy:
“`ruby
# Rakefile
task :deploy => :generate do
# Deploy static site
sh “rsync -avz public/ user@server:/var/www/site/”
# Deploy protected content
sh “rsync -avz public/_paywall_content/ user@server:/var/www/paywall_content/”
end
“`
Results
The final system provides:
- ✅ Secure – Content not accessible via source code
- ✅ Seamless – No passwords to remember
- ✅ Fast – Static site performance maintained
- ✅ Flexible – Works across subscription platforms
- ✅ Reliable – Email prefetch protection, error handling
- ✅ Developer-friendly – Works in dev and production
Code Availability
The complete implementation includes:
- Jekyll plugin for content extraction
- Sinatra API service
- JavaScript revealing module
- SCSS styling for modals and notifications
- Database schema and migration scripts
Feel free to reach out if you have questions about implementing a similar system for your own site! I’m happy to share any of the code, and to offer guidance, assuming you have enough baseline knowledge to manage customizing it for yourself.
Building this system taught me a lot about authentication, security, and the balance between user experience and protection. The magic link approach might not be right for every use case, but for a membership site with email-verified subscribers, it should be an elegant solution.
[endpaywall]

Leave a Reply