Hey there fellow coders! Today I’m diving into implementing plan-based access control in Node.js applications – that essential system where users get different features based on their subscription tier. I’ll show you how to build feature restrictions using Express and MongoDB, with complete code examples you can adapt for your own projects. Whether you’re creating a freemium app or a multi-tiered SaaS platform, this guide has you covered!

Implementing Plan and Membership Based Access Control in Node.js Applications

Introduction: What We’ll Learn Today

Hey there fellow coders! Today I’m gonna dive into something that’s super useful for any subscription-based app or service – implementing plan and membership based access control. You know, that system where free users get some basic stuff, but premium users get all the cool features? Yeah, that one!

We’ll go through how to build this from scratch using Node.js, Express, and Mongoose. I’ll cover everything from setting up the plan model, creating middleware for access control, and even how to make your system scalable as your user base grows.

By the end of this article, you’ll have a solid understanding of how to implement access restrictions based on user plans, and have some code examples ready to use in your own projects. So let’s get started!

pbac in node js

also checkout best services we provide with highly skilled developers team.

Understanding Plan-Based Access Control

Before we dive into the code, let’s break down what plan-based access control actually means. It’s basically a system that restricts what users can do based on the plan or subscription tier they’ve chosen.

In our example today, we’ll focus on a simple case with two plans:

  1. Free Plan – Users can add up to 2 products and access only basic tools
  2. Pro Plan – Users can add up to 10 products and access premium tools

These kinds of restrictions are super common in SaaS applications, e-commerce platforms, and virtually any service with a “freemium” model.

Setting Up Our Plan Model

First things first, we need a way to define our plans in the database. Let’s create a simple Mongoose model for this:

// models/Plan.js
const mongoose = require('mongoose');

const PlanSchema = new mongoose.Schema({
  name: {
    type: String,
    required: true,
    unique: true,
    trim: true
  },
  description: {
    type: String,
    required: true
  },
  price: {
    type: Number,
    default: 0
  },
  features: {
    productLimit: {
      type: Number,
      required: true
    },
    hasPremiumTools: {
      type: Boolean,
      default: false
    }
    // You can add more features as needed
  },
  isActive: {
    type: Boolean,
    default: true
  },
  createdAt: {
    type: Date,
    default: Date.now
  }
});

module.exports = mongoose.model('Plan', PlanSchema);

This model defines the basic structure for our plans. Each plan has a name, description, price, and a set of features. For our example, we’re focusing on two features: the product limit and whether the plan has access to premium tools.

Know 2025 best js frameworks for web development.

Creating Sample Plan Data

Now let’s create some sample plan data that we can use to seed our database:

// seedData/plans.js
const plans = [
  {
    name: 'Free',
    description: 'Basic plan with limited features',
    price: 0,
    features: {
      productLimit: 2,
      hasPremiumTools: false
    },
    isActive: true
  },
  {
    name: 'Pro',
    description: 'Premium plan with all features',
    price: 9.99,
    features: {
      productLimit: 10,
      hasPremiumTools: true
    },
    isActive: true
  }
];

module.exports = plans;

To seed this data into our database, we can create a simple script:

// scripts/seedPlans.js
const mongoose = require('mongoose');
const Plan = require('../models/Plan');
const plans = require('../seedData/plans');

mongoose.connect('mongodb://localhost:27017/myapp', {
  useNewUrlParser: true,
  useUnifiedTopology: true
});

async function seedPlans() {
  try {
    // Clear existing plans
    await Plan.deleteMany({});
    
    // Insert new plans
    const createdPlans = await Plan.insertMany(plans);
    
    console.log(`${createdPlans.length} plans created successfully!`);
    mongoose.connection.close();
  } catch (error) {
    console.error('Error seeding plans:', error);
    mongoose.connection.close();
  }
}

seedPlans();

User Model with Plan Reference

Next, we need to modify our User model to include a reference to the plan they’ve subscribed to:

// models/User.js
const mongoose = require('mongoose');

const UserSchema = new mongoose.Schema({
  name: {
    type: String,
    required: true
  },
  email: {
    type: String,
    required: true,
    unique: true,
    trim: true
  },
  password: {
    type: String,
    required: true
  },
  plan: {
    type: mongoose.Schema.Types.ObjectId,
    ref: 'Plan',
    required: true
  },
  planStartDate: {
    type: Date,
    default: Date.now
  },
  planEndDate: {
    type: Date
  },
  createdAt: {
    type: Date,
    default: Date.now
  }
});

module.exports = mongoose.model('User', UserSchema);

Creating Access Control Utilities

Now comes the fun part – implementing the access control logic. We’ll create a utility function that checks if a user has access to a specific feature based on their plan:

// utils/accessControl.js
const User = require('../models/User');

/**
 * Check if user has reached their product limit
 * @param {string} userId - The user ID
 * @param {number} currentProductCount - Current number of products user has
 * @returns {Promise<boolean>} - True if user can add more products
 */
async function canAddProduct(userId, currentProductCount) {
  try {
    const user = await User.findById(userId).populate('plan');
    
    if (!user) {
      throw new Error('User not found');
    }
    
    return currentProductCount < user.plan.features.productLimit;
  } catch (error) {
    console.error('Error checking product limit:', error);
    return false;
  }
}

/**
 * Check if user has access to premium tools
 * @param {string} userId - The user ID
 * @returns {Promise<boolean>} - True if user has access to premium tools
 */
async function hasPremiumAccess(userId) {
  try {
    const user = await User.findById(userId).populate('plan');
    
    if (!user) {
      throw new Error('User not found');
    }
    
    return user.plan.features.hasPremiumTools;
  } catch (error) {
    console.error('Error checking premium access:', error);
    return false;
  }
}

module.exports = {
  canAddProduct,
  hasPremiumAccess
};

Creating Middleware for Route Protection

To make our access control system more usable, let’s create some Express middleware to protect routes based on plan features:

// middleware/planCheck.js
const { canAddProduct, hasPremiumAccess } = require('../utils/accessControl');
const Product = require('../models/Product');

/**
 * Middleware to check if user can add a new product
 */
async function checkProductLimit(req, res, next) {
  try {
    const userId = req.user.id; // Assuming you have authentication middleware that sets req.user
    
    // Count user's existing products
    const productCount = await Product.countDocuments({ user: userId });
    
    // Check if user can add more products
    const canAdd = await canAddProduct(userId, productCount);
    
    if (!canAdd) {
      return res.status(403).json({ 
        success: false, 
        message: 'Product limit reached for your plan. Please upgrade to add more products.' 
      });
    }
    
    next();
  } catch (error) {
    console.error('Product limit check error:', error);
    res.status(500).json({ success: false, message: 'Server error' });
  }
}

/**
 * Middleware to check if user has premium access
 */
async function checkPremiumAccess(req, res, next) {
  try {
    const userId = req.user.id; // Assuming you have authentication middleware
    
    // Check if user has premium access
    const hasPremium = await hasPremiumAccess(userId);
    
    if (!hasPremium) {
      return res.status(403).json({ 
        success: false, 
        message: 'This feature requires a premium plan. Please upgrade to access premium tools.' 
      });
    }
    
    next();
  } catch (error) {
    console.error('Premium access check error:', error);
    res.status(500).json({ success: false, message: 'Server error' });
  }
}

module.exports = {
  checkProductLimit,
  checkPremiumAccess
};

Implementing the Routes

Now let’s put it all together by implementing some routes that use our middleware:

// routes/productRoutes.js
const express = require('express');
const router = express.Router();
const auth = require('../middleware/auth');
const { checkProductLimit } = require('../middleware/planCheck');
const Product = require('../models/Product');

// @route   POST /api/products
// @desc    Create a new product
// @access  Private + Plan Check
router.post('/', [auth, checkProductLimit], async (req, res) => {
  try {
    const { name, description, price } = req.body;
    
    const newProduct = new Product({
      name,
      description,
      price,
      user: req.user.id
    });
    
    const product = await newProduct.save();
    
    res.json(product);
  } catch (error) {
    console.error('Error creating product:', error);
    res.status(500).send('Server error');
  }
});

// Other product routes...

module.exports = router;
// routes/toolRoutes.js
const express = require('express');
const router = express.Router();
const auth = require('../middleware/auth');
const { checkPremiumAccess } = require('../middleware/planCheck');

// @route   GET /api/tools/premium
// @desc    Access premium tools
// @access  Private + Premium Check
router.get('/premium', [auth, checkPremiumAccess], async (req, res) => {
  try {
    // Premium tools logic here
    res.json({ 
      success: true, 
      message: 'Welcome to premium tools!',
      tools: [
        { id: 1, name: 'Advanced Analytics' },
        { id: 2, name: 'Bulk Editor' },
        { id: 3, name: 'AI Recommendations' }
      ]
    });
  } catch (error) {
    console.error('Error accessing premium tools:', error);
    res.status(500).send('Server error');
  }
});

// Other tool routes...

module.exports = router;

Configuring the App

Finally, let’s set up the main Express app:

// app.js
const express = require('express');
const mongoose = require('mongoose');
const productRoutes = require('./routes/productRoutes');
const toolRoutes = require('./routes/toolRoutes');
const planRoutes = require('./routes/planRoutes');

const app = express();

// Connect to MongoDB
mongoose.connect('mongodb://localhost:27017/myapp', {
  useNewUrlParser: true,
  useUnifiedTopology: true
});

// Middleware
app.use(express.json());

// Routes
app.use('/api/products', productRoutes);
app.use('/api/tools', toolRoutes);
app.use('/api/plans', planRoutes);

// Start server
const PORT = process.env.PORT || 5000;
app.listen(PORT, () => console.log(`Server running on port ${PORT}`));

Making Your Plan-Based Access System Scalable and Fast

Now, let’s talk about how to make this system more scalable and performant for when your user base grows. Here are some tips:

1. Implement Caching

One of the biggest bottlenecks in our current implementation is that we’re querying the database every time we need to check a user’s plan. Let’s use Redis to cache user plan information:

// utils/accessControlWithCache.js
const Redis = require('ioredis');
const User = require('../models/User');

// Create Redis client
const redis = new Redis({
  host: 'localhost',
  port: 6379
});

/**
 * Get user's plan features from cache or database
 * @param {string} userId - The user ID
 * @returns {Promise<Object>} - Plan features
 */
async function getUserPlanFeatures(userId) {
  try {
    // Try to get from cache first
    const cacheKey = `user:${userId}:plan`;
    const cachedPlan = await redis.get(cacheKey);
    
    if (cachedPlan) {
      return JSON.parse(cachedPlan);
    }
    
    // If not in cache, get from database
    const user = await User.findById(userId).populate('plan');
    
    if (!user) {
      throw new Error('User not found');
    }
    
    // Store in cache for 1 hour
    await redis.set(
      cacheKey, 
      JSON.stringify(user.plan.features), 
      'EX', 
      3600
    );
    
    return user.plan.features;
  } catch (error) {
    console.error('Error getting user plan:', error);
    throw error;
  }
}

/**
 * Check if user has reached their product limit
 * @param {string} userId - The user ID
 * @param {number} currentProductCount - Current number of products user has
 * @returns {Promise<boolean>} - True if user can add more products
 */
async function canAddProduct(userId, currentProductCount) {
  try {
    const features = await getUserPlanFeatures(userId);
    return currentProductCount < features.productLimit;
  } catch (error) {
    console.error('Error checking product limit:', error);
    return false;
  }
}

/**
 * Check if user has access to premium tools
 * @param {string} userId - The user ID
 * @returns {Promise<boolean>} - True if user has access to premium tools
 */
async function hasPremiumAccess(userId) {
  try {
    const features = await getUserPlanFeatures(userId);
    return features.hasPremiumTools;
  } catch (error) {
    console.error('Error checking premium access:', error);
    return false;
  }
}

/**
 * Invalidate user plan cache when plan changes
 * @param {string} userId - The user ID
 */
async function invalidateUserPlanCache(userId) {
  const cacheKey = `user:${userId}:plan`;
  await redis.del(cacheKey);
}

module.exports = {
  canAddProduct,
  hasPremiumAccess,
  invalidateUserPlanCache
};

2. Add Cache Invalidation When Plan Changes

When a user upgrades or changes their plan, we need to invalidate the cache:

// routes/userRoutes.js
const express = require('express');
const router = express.Router();
const auth = require('../middleware/auth');
const User = require('../models/User');
const { invalidateUserPlanCache } = require('../utils/accessControlWithCache');

// @route   PUT /api/users/plan
// @desc    Update user's plan
// @access  Private
router.put('/plan', auth, async (req, res) => {
  try {
    const { planId } = req.body;
    
    // Update user's plan
    const user = await User.findByIdAndUpdate(
      req.user.id,
      { 
        plan: planId,
        planStartDate: Date.now()
      },
      { new: true }
    );
    
    // Invalidate cache
    await invalidateUserPlanCache(req.user.id);
    
    res.json(user);
  } catch (error) {
    console.error('Error updating plan:', error);
    res.status(500).send('Server error');
  }
});

module.exports = router;

3. Use Background Workers for Plan Status Updates

To handle plan expirations and renewals more efficiently, we can use a background worker:

// workers/planStatusWorker.js
const cron = require('node-cron');
const User = require('../models/User');
const Plan = require('../models/Plan');
const { invalidateUserPlanCache } = require('../utils/accessControlWithCache');

// Run every day at midnight
cron.schedule('0 0 * * *', async () => {
  try {
    console.log('Running plan status update worker...');
    
    // Find users with expired plans
    const expiredUsers = await User.find({
      planEndDate: { $lt: new Date() }
    });
    
    // Get free plan
    const freePlan = await Plan.findOne({ name: 'Free' });
    
    if (!freePlan) {
      throw new Error('Free plan not found');
    }
    
    // Update expired users to free plan
    for (const user of expiredUsers) {
      user.plan = freePlan._id;
      await user.save();
      
      // Invalidate cache
      await invalidateUserPlanCache(user._id);
      
      console.log(`User ${user._id} reverted to free plan`);
    }
    
    console.log('Plan status update completed');
  } catch (error) {
    console.error('Plan status update error:', error);
  }
});

4. Implement Database Indexing

To improve database query performance, add the right indexes:

// models/User.js (updated)
const mongoose = require('mongoose');

const UserSchema = new mongoose.Schema({
  name: {
    type: String,
    required: true
  },
  email: {
    type: String,
    required: true,
    unique: true,
    trim: true
  },
  password: {
    type: String,
    required: true
  },
  plan: {
    type: mongoose.Schema.Types.ObjectId,
    ref: 'Plan',
    required: true,
    index: true // Add index here
  },
  planStartDate: {
    type: Date,
    default: Date.now
  },
  planEndDate: {
    type: Date,
    index: true // Add index here
  },
  createdAt: {
    type: Date,
    default: Date.now
  }
});

// Add compound index for queries that might filter by both user and plan
UserSchema.index({ plan: 1, planEndDate: 1 });

module.exports = mongoose.model('User', UserSchema);

5. Implement Rate Limiting

To prevent abuse of your API, add rate limiting:

// middleware/rateLimit.js
const rateLimit = require('express-rate-limit');
const RedisStore = require('rate-limit-redis');
const Redis = require('ioredis');

const redis = new Redis({
  host: 'localhost',
  port: 6379
});

// Create rate limiter
const apiLimiter = rateLimit({
  store: new RedisStore({
    client: redis,
    prefix: 'rate-limit:'
  }),
  windowMs: 15 * 60 * 1000, // 15 minutes
  max: 100, // Limit each IP to 100 requests per windowMs
  standardHeaders: true,
  legacyHeaders: false
});

module.exports = apiLimiter;

Then apply it to your routes:

// app.js (updated)
const express = require('express');
const mongoose = require('mongoose');
const apiLimiter = require('./middleware/rateLimit');
const productRoutes = require('./routes/productRoutes');
const toolRoutes = require('./routes/toolRoutes');
const planRoutes = require('./routes/planRoutes');

const app = express();

// Connect to MongoDB
mongoose.connect('mongodb://localhost:27017/myapp', {
  useNewUrlParser: true,
  useUnifiedTopology: true
});

// Middleware
app.use(express.json());

// Apply rate limiter to all API routes
app.use('/api/', apiLimiter);

// Routes
app.use('/api/products', productRoutes);
app.use('/api/tools', toolRoutes);
app.use('/api/plans', planRoutes);

// Start server
const PORT = process.env.PORT || 5000;
app.listen(PORT, () => console.log(`Server running on port ${PORT}`));

Tips for Making Your Plan-Based Access System Better

Here are some additional tips to make your system even better:

1. Implement a Feature Flags System

Instead of hardcoding features in the plan model, create a more flexible system using feature flags:

// models/Plan.js (updated with feature flags)
const mongoose = require('mongoose');

const PlanSchema = new mongoose.Schema({
  name: {
    type: String,
    required: true,
    unique: true,
    trim: true
  },
  description: {
    type: String,
    required: true
  },
  price: {
    type: Number,
    default: 0
  },
  features: {
    type: Map,
    of: mongoose.Schema.Types.Mixed,
    default: {}
  },
  isActive: {
    type: Boolean,
    default: true
  },
  createdAt: {
    type: Date,
    default: Date.now
  }
});

module.exports = mongoose.model('Plan', PlanSchema);

With this approach, you can dynamically add or remove features without changing your schema.

2. Add Usage Tracking

Track how many resources users are consuming:

// models/UserUsage.js
const mongoose = require('mongoose');

const UserUsageSchema = new mongoose.Schema({
  user: {
    type: mongoose.Schema.Types.ObjectId,
    ref: 'User',
    required: true
  },
  productCount: {
    type: Number,
    default: 0
  },
  apiCallsCount: {
    type: Number,
    default: 0
  },
  premiumToolsUsage: {
    type: Number,
    default: 0
  },
  month: {
    type: Number,
    required: true
  },
  year: {
    type: Number,
    required: true
  },
  lastUpdated: {
    type: Date,
    default: Date.now
  }
});

// Compound index for querying by user and time period
UserUsageSchema.index({ user: 1, year: 1, month: 1 }, { unique: true });

module.exports = mongoose.model('UserUsage', UserUsageSchema);

3. Use Server-Side Feature Flags

For even more flexibility, use a feature flags service:

// utils/featureFlags.js
const LaunchDarkly = require('launchdarkly-node-server-sdk');

// Initialize LaunchDarkly client
const ldClient = LaunchDarkly.init('YOUR_SDK_KEY');

/**
 * Check if a feature is enabled for a user
 * @param {Object} user - User object
 * @param {string} featureKey - Feature flag key
 * @param {boolean} defaultValue - Default value if flag is not found
 * @returns {Promise<boolean>} - True if feature is enabled
 */
async function isFeatureEnabled(user, featureKey, defaultValue = false) {
  try {
    await ldClient.waitForInitialization();
    
    const context = {
      kind: 'user',
      key: user._id.toString(),
      name: user.name,
      email: user.email,
      custom: {
        planId: user.plan.toString(),
        planName: user.planName
      }
    };
    
    return await ldClient.variation(featureKey, context, defaultValue);
  } catch (error) {
    console.error('Feature flag error:', error);
    return defaultValue;
  }
}

module.exports = {
  isFeatureEnabled
};

4. Implement a Monthly Usage Reset Job

To reset usage counters at the beginning of each month:

// workers/usageResetWorker.js
const cron = require('node-cron');
const UserUsage = require('../models/UserUsage');

// Run at midnight on the first day of each month
cron.schedule('0 0 1 * *', async () => {
  try {
    console.log('Creating new usage records for the month...');
    
    const now = new Date();
    const month = now.getMonth() + 1;
    const year = now.getFullYear();
    
    // Get all users from the previous month
    const lastMonth = month === 1 ? 12 : month - 1;
    const lastYear = month === 1 ? year - 1 : year;
    
    const previousUsages = await UserUsage.find({
      month: lastMonth,
      year: lastYear
    });
    
    // Create new records for the current month
    const newUsages = previousUsages.map(usage => ({
      user: usage.user,
      productCount: 0, // Reset to 0
      apiCallsCount: 0, // Reset to 0
      premiumToolsUsage: 0, // Reset to 0
      month,
      year,
      lastUpdated: now
    }));
    
    if (newUsages.length > 0) {
      await UserUsage.insertMany(newUsages);
      console.log(`Created ${newUsages.length} new usage records`);
    }
    
    console.log('Usage reset completed');
  } catch (error) {
    console.error('Usage reset error:', error);
  }
});

Read More Blogs.

Conclusion: Building a Robust Plan-Based Access Control System

We’ve covered a lot of ground in this article! We started with a simple implementation of plan-based access control and then explored ways to make it more scalable and performant.

Here’s a recap of what we learned:

  1. How to create a basic plan model with different features and limitations
  2. How to implement access control middleware to restrict access based on plan
  3. How to use caching to improve performance
  4. How to implement cache invalidation when plans change
  5. How to use background workers for plan status updates
  6. How to optimize database queries with indexing
  7. How to add rate limiting to protect your API
  8. How to make your system more flexible with feature flags
  9. How to track usage and implement monthly resets

Remember, the implementation you choose should depend on your specific needs. For smaller applications, the basic implementation might be enough. But as your application grows, you’ll want to implement more advanced features like caching and background workers.

I hope this article helped you understand how to implement plan and membership based access control in your Node.js applications. Feel free to adapt these examples to fit your specific use case.

Happy coding!

Meta Title: Implementing Plan and Membership Based Access Control in Node.js | Complete Guide Meta Description: Learn how to build a scalable plan-based access control system with Node.js, Express, and MongoDB. Includes code examples and best practices for caching and performance optimization.

Leave a Reply

Your email address will not be published. Required fields are marked *