Why This Project?
You've probably seen dozens of payment tutorials that stop at "add Stripe, hit submit." Cool, but what if you want to:
- Lock down the payment route so only authorized users can access it?
- Dynamically show different UI based on user roles (e.g., customer, admin)?
- Actually make it secure with real authentication and authorization?
That's what this guide is about.
- We're going to:
- Use Next.js App Router for UI + routing
- Plug in Firebase for auth (sign up, log in)
- Layer in Permit.io to enforce access based on user roles
- Deploy it on Vercel, ready for the real world.
You can play around with the live application here and access the code in this github repo.

Getting Started
Step 1: Create the Next.js App
npx create-next-app@latest custom-payment-gateway
cd custom-payment-gateway
Step 2: Install Dependencies
npm install firebase firebase-admin axios permitio tailwindcss postcss autoprefixer
npx tailwindcss init -p
Step 3: Setup Tailwind CSS
tailwind.config.js
content: [
"./app/**/*.{js,ts,jsx,tsx}",
"./components/**/*.{js,ts,jsx,tsx}"
],
Create app/globals.css with base styles:
body {
font-family: sans-serif;
background-color: #f9f9f9;
color: #111;
}
Firebase Auth Setup
You'll use Firebase authentication for signing users up and generating ID tokens.
In your Firebase project:
- Enable Email/Password Auth
- Generate a service account key (used by server-side Firebase Admin SDK)
Then set these as environment variables:
FIREBASE_PROJECT_ID=your_project_id
FIREBASE_CLIENT_EMAIL=your_email
FIREBASE_PRIVATE_KEY="your_key"
Use these to initialize Firebase Admin SDK in lib/firebase-admin.ts:
import { cert, getApps, initializeApp } from 'firebase-admin/app';
import { getAuth } from 'firebase-admin/auth';
if (!getApps().length) {
initializeApp({
credential: cert({
projectId: process.env.FIREBASE_PROJECT_ID,
clientEmail: process.env.FIREBASE_CLIENT_EMAIL,
privateKey: process.env.FIREBASE_PRIVATE_KEY?.replace(/\\n/g, '\n')
})
});
}
export const adminAuth = getAuth();
This code initializes the Firebase Admin SDK using credentials from environment variables, ensuring it only runs once even if the file is imported multiple times. It then exports the adminAuth instance, which provides access to Firebase Authentication functions for server-side use.
Setting Up Permit.io - Access Control, Sorted
Think of Permit.io as your app's bouncer. It decides who can enter VIP (your protected pages).
Step 1: Create a Free Permit.io Project
Go to permit.io → Sign up → Create project → Add environment → Add resource payment

Permit.io provides a clear separation between development and production environments, helping teams manage access control safely. In the Development environment, access enforcement is active, with 5 users, 3 defined roles, and 1 connected resource. The Production environment, however, is still in the setup phase, awaiting user connections and role configurations. This structure ensures that changes can be tested thoroughly before being pushed live, reducing the risk of security issues or disruptions.
Step 2: Define Actions + Roles
resource: payment
actions:
- pay
- cancel
- process
roles:
- customer
- admin

Above here shows the key roles and the access they have. The Permit.io dashboard provides a detailed view of the roles and permissions assigned across environments. In the Development environment, three roles are actively managing access control among five users, with enforced access already enabled. Each role defines specific access policies and governs how users interact with the system resources. Meanwhile, the Production environment is configured but not yet enforcing access, ensuring that permissions can be carefully tested and reviewed before deployment to live users.
Step 3: Connect SDK to Your App
Create lib/permit.ts:
import { Permit } from '@permit.io/sdk';
const permit = new Permit({
token: process.env.PERMIT_API_KEY,
pdp: process.env.PERMIT_IO_PDP_URL,
});
export default permit;
Step 4: Assign Roles via API
The below happens automatically when a user signs up
await permit.api.users.sync({
key: email,
email,
first_name: name,
});
await permit.api.roles.assign(role, email);
Done. You've got policy-based access without writing middleware logic.
Signup + Login Flows (Firebase + Permit.io)
Let's wire up the user flows. Both use Firebase Auth on the frontend, with Permit.io logic handled via API routes.
Signup Page
The inclusion of a password visibility toggle enhances usability, allowing users to verify their input without compromising security. Additionally, the role selector is a thoughtful touch that enables customized user experiences based on their function within the platform.
// src/app/signup/page.tsx
const handleSignup = async () => {
const userCredential = await createUserWithEmailAndPassword(auth, email, password);
const user = userCredential.user;
await updateProfile(user, { displayName: name });
const idToken = await user.getIdToken();
await axios.post("/api/signup", {
idToken,
role,
email: user.email,
name,
});
router.push("/payment");
};
This code defines an asynchronous handleSignup function that manages user registration using Firebase Authentication. It creates a new user with an email and password, updates their profile with a display name, and retrieves an ID token for authentication. The function then sends the user's details, including role and token, to a backend API for further processing. Finally, it redirects the user to the payment page, completing the signup workflow.

The above shows a user signup page, this page shown above exemplifies a clean, intuitive, and user-centric approach to onboarding users onto a digital payment platform. With a minimalist layout and clear input fields, new users can easily create an account by entering their full name, email, password, and selecting their role (e.g., Customer, Admin, etc.) from a dropdown menu.
/api/signup Route
const { idToken, email, role, name } = await req.json();
await auth.verifyIdToken(idToken);
await permit.api.users.sync({ key: email, email, first_name: name });
await permit.api.roles.assign(role, email);
Login Page
// src/app/login/page.tsx
const handleLogin = async () => {
const userCredential = await signInWithEmailAndPassword(auth, email, password);
const idToken = await userCredential.user.getIdToken();
await axios.post("/api/set-token", { idToken });
router.push("/payment");
};
/api/set-token Route
cookies().set("idToken", idToken, { httpOnly: true, secure: true });
Absolutely! Here's your full Dev.to-ready Markdown version—all in one piece so you can copy and paste directly:
# Protected Payment Page (Only If You're Allowed)
Now for the fun part—creating the payment form and locking it behind **role-based access control**.
## `/payment/page.tsx`
```
typescript
const idToken = cookies().get("idToken")?.value;
const decoded = await adminAuth.verifyIdToken(idToken);
const email = decoded.email;
const hasAccess = await permit.check(email, "pay", "payment");
if (!hasAccess) {
return <p>You do not have permission to view this page.</p>;
}
return <PaymentForm />;
/components/PaymentForm.tsx
typescript
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
const res = await axios.post("/api/payment", {
name, email, amount
});
setResponse(res.data);
};
The /payment/page.tsx code verifies the user's identity by decoding their Firebase ID token using adminAuth. It extracts the user's email and checks their permission to access the payment page through a role-based access control system (permit.check). If unauthorized, it displays a permission error; otherwise, it renders the PaymentForm component.
Inside PaymentForm.tsx, the handleSubmit function handles form submissions by sending a POST request to /api/payment with the user's name, email, and amount, then stores the server response.
The payment form provides a clean, user-friendly interface for completing transactions. Users provide essential details such as cardholder name, role, and the desired payment amount. The system calculates fees and total amounts dynamically, ensuring a transparent, secure, and smooth payment experience.
The login page is simple and functional. Users enter their registered email and password to access accounts, with a password visibility toggle and a direct link to the signup page. This ensures seamless and secure authentication.
Enforce HTTPS and TLS in Production
In production, it's crucial to enforce HTTPS and TLS to protect data in transit. All communication between client and server is encrypted, preventing sensitive information exposure. Always redirect HTTP to HTTPS and maintain updated TLS certificates.
javascript
// next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
async headers() {
return [
{
source: "/:path*",
headers: [
{
key: "Strict-Transport-Security",
value: "max-age=31536000; includeSubDomains; preload",
},
],
},
];
},
};
module.exports = nextConfig;
Key points:
- HTTPS enforced via
next.config.jsand Vercel - Local development HTTPS supported via mkcert
Validating User Input and Preventing Vulnerabilities
- Client-side validation in
PaymentFormfor all fields - Server-side validation and sanitization in
/api/paymentto prevent XSS and injections - Secure headers to mitigate clickjacking, XSS, and MIME-type sniffing
- Custom rate limiting to prevent abuse
/api/payment/route.ts
javascript
const { amount, email, name } = await req.json();
console.log(`Processing $${amount} for ${name}`);
return new Response(JSON.stringify({
success: true,
message: "Payment successful",
name, email, amount
}), { status: 200 });
This snippet reads JSON data from the request, logs the transaction, and responds with a success message including the name, email, and amount.
After completing payment, users see a confirmation screen showing cardholder info, payment amount, and transaction reference. Users can print the receipt or return to login.
Auth + Access Flow Diagram
When a user signs up or logs in:
User → Signup/Login → Get Firebase Token
→ Set Cookie → Visit /payment → Server Verifies Token
→ Permit.io: Can this user "pay" on "payment"?
├── Yes → Show Payment Form
└── No → Block with Unauthorized
Deploying to Production (Vercel FTW)
Step 1: Push Code to GitHub
bash
git init
git remote add origin https://github.com/your/repo.git
git push -u origin main
Step 2: Connect GitHub to Vercel
- Go to vercel.com
- Connect your GitHub repo
- Add environment variables in the Vercel dashboard:
bash
# Permit.io (server-side)
PERMIT_API_KEY=<your-permit-api-key>
PERMIT_IO_PDP_URL=<permit-io-PDP-URL>
# Firebase Admin SDK (server-side)
FIREBASE_PROJECT_ID=<your-firebase-project-id>
FIREBASE_CLIENT_EMAIL=<your-firebase-client-email>
FIREBASE_PRIVATE_KEY="your-firebase-private-key"
# Custom Payment Gateway (server-side)
PAYMENT_GATEWAY_SECRET=<your-custom-payment-secret>
Replace placeholders with your actual credentials. Server-side only for security.
Step 3: Deploy
Hit Deploy and watch the magic happen. Test and explore the live application here.
Monitor & Debug Like a Pro
- Use
console.log()in frontend & backend to trace payment flow - Monitor Firebase logs in the Firebase console
- Check Permit.io audit trail for denied actions
- Set breakpoints in Vercel deployment logs
Final Thoughts: You Did It.
We just built a secure, role-aware, seamless payment gateway using Next.js, Firebase, and Permit.io.
What we accomplished:
- Full-stack app with real authentication
- Real-time authorization using roles
- Secure payment gateway (mocked)
- Production-ready deployment viewable here




Top comments (0)