Most admin panels live at /admin. Every bot, every script kiddie, every automated scanner on the internet knows to try /admin, /dashboard, /wp-admin, and every other predictable path. When you are building a payment platform that handles real money, that predictability is a liability.
In Session 020, we built the first version of the 0fee.dev admin panel. The design philosophy was simple: make it functional, make it secure, and make it invisible to anyone who does not have the URL.
Security Through Obscurity -- As a First Layer
The admin panel does not live at /admin. It lives at a UUID-based route:
https://0fee.dev/7f8e9c2a-3b4d-4e5f-a6b7-8c9d0e1f2a3bLet us be clear about what this is and what it is not. Security through obscurity is not a security strategy on its own. It is a supplementary layer. The admin panel still requires JWT authentication with 8-hour token expiry. It still enforces role-based access control. The UUID route simply means that automated scanners will never find the login page in the first place.
Think of it as the difference between putting a lock on a door that faces a busy street versus putting a lock on a door hidden behind a bookshelf. Both doors are locked. But only one gets tested by every passerby.
python# routes/admin.py
from fastapi import APIRouter
ADMIN_ROUTE_PREFIX = "/7f8e9c2a-3b4d-4e5f-a6b7-8c9d0e1f2a3b"
router = APIRouter(
prefix=ADMIN_ROUTE_PREFIX,
tags=["admin"],
dependencies=[Depends(require_admin_role)]
)The UUID is stored as an environment variable in production, which means it can be rotated without code changes if it is ever leaked.
JWT Authentication With 8-Hour Expiry
Admin authentication uses JWT tokens with deliberately short expiry windows. While the main API issues tokens with 24-hour expiry, admin tokens expire after 8 hours. The reasoning is straightforward: admin sessions correspond to working sessions. Nobody needs an admin token that persists overnight.
python# services/admin_auth.py
from datetime import datetime, timedelta
from jose import jwt
ADMIN_TOKEN_EXPIRY = timedelta(hours=8)
def create_admin_token(user_id: str, role: str) -> str:
payload = {
"sub": user_id,
"role": role,
"type": "admin",
"exp": datetime.utcnow() + ADMIN_TOKEN_EXPIRY,
"iat": datetime.utcnow(),
}
return jwt.encode(payload, SECRET_KEY, algorithm="HS256")
def verify_admin_token(token: str) -> dict: payload = jwt.decode(token, SECRET_KEY, algorithms=["HS256"]) if payload.get("type") != "admin": raise HTTPException(status_code=401, detail="Not an admin token") return payload ```
The token type field ("type": "admin") prevents regular API tokens from being used to access admin endpoints. Even if someone intercepts a standard API token, it will be rejected at the admin gate.
The Four-Tier Role Hierarchy
Payment platforms have different levels of access. The person who monitors transactions should not be able to create coupons. The person who creates coupons should not be able to manage users. We implemented a four-tier role hierarchy:
| Role | Level | Capabilities |
|---|---|---|
| superadmin | 4 | Full platform access. User management, configuration, data export. Only Thales has this role. |
| admin | 3 | Transaction management, app management, coupon creation, stats access. |
| manager | 2 | View transactions, manage specific apps, limited stats. |
| auditor | 1 | Read-only access to transactions and stats. Cannot modify anything. |
The hierarchy is enforced through a numeric level system, which makes permission checks simple:
python# models/admin.py
from enum import IntEnum
class AdminRole(IntEnum):
AUDITOR = 1
MANAGER = 2
ADMIN = 3
SUPERADMIN = 4
def require_role(minimum_role: AdminRole): """Dependency that enforces minimum role level.""" def checker(current_user: dict = Depends(get_current_admin)): user_role = AdminRole[current_user["role"].upper()] if user_role < minimum_role: raise HTTPException( status_code=403, detail=f"Requires {minimum_role.name} role or higher" ) return current_user return checker ```
This pattern means adding a new permission check is a one-liner:
python@router.delete("/users/{user_id}", dependencies=[Depends(require_role(AdminRole.SUPERADMIN))])
async def delete_user(user_id: str):
...
@router.get("/transactions", dependencies=[Depends(require_role(AdminRole.AUDITOR))])
async def list_transactions():
...Auditors can list transactions. Only superadmins can delete users. The hierarchy is intuitive and easy to reason about.
The Seven Admin Endpoints
The MVP admin panel shipped with seven core endpoints, each mapped to a specific operational need:
1. User Management
python@router.get("/users")
async def list_users(
page: int = 1,
per_page: int = 50,
search: str = None,
status: str = None,
):
"""List all platform users with filtering and pagination."""
...
@router.patch("/users/{user_id}")
async def update_user(user_id: str, data: AdminUserUpdate):
"""Update user status, role, or metadata."""
...Listing users supports search by email or name, filtering by status (active, suspended, banned), and pagination. The update endpoint allows changing user status and assigning roles, but it enforces the rule that only a superadmin can promote someone to admin or higher.
2. Application Management
python@router.get("/apps")
async def list_apps(page: int = 1, per_page: int = 50):
"""List all registered applications across all users."""
...
@router.get("/apps/{app_id}")
async def get_app_detail(app_id: str):
"""Full application detail including provider configurations."""
...The app management endpoints give admins visibility into every application registered on the platform. This includes which providers are configured, whether the app is in test or live mode, and its transaction history summary.
3. Transaction Overview
python@router.get("/transactions")
async def list_transactions(
page: int = 1,
per_page: int = 50,
status: str = None,
provider: str = None,
date_from: str = None,
date_to: str = None,
):
"""Platform-wide transaction listing with filters."""
...This is arguably the most important admin endpoint. It shows every transaction across every application on the platform. Filters include status (pending, completed, failed, refunded), provider, date range, and amount range. This is essential for debugging payment issues and monitoring platform health.
4. Coupon Management
python@router.post("/coupons")
async def create_coupon(data: CouponCreate):
"""Create a new promotional coupon."""
...
@router.get("/coupons")
async def list_coupons():
"""List all coupons with usage stats."""
...Coupons give discounts on 0fee's processing fee. They have configurable parameters: percentage discount, maximum uses, expiry date, and eligible apps. The creation endpoint validates that the discount does not exceed 100% (a free coupon is valid, but a negative-fee coupon is not).
5. Platform Statistics
python@router.get("/stats")
async def get_platform_stats(period: str = "30d"):
"""Aggregated platform statistics."""
...The stats endpoint returns aggregated data: total transaction volume, transaction count by status, revenue from fees, active users, and growth metrics. The period parameter supports 7d, 30d, 90d, and all.
The Platform App: user_0fee_platform
Every transaction on 0fee flows through an application. But what about transactions that the platform itself initiates -- test payments, internal transfers, fee collection? We created a special platform application:
python# seed/platform_app.py
PLATFORM_APP = {
"id": "app_0fee_platform",
"user_id": "user_0fee_platform",
"name": "0fee Platform",
"mode": "live",
"is_platform": True,
"providers": {
"test": {
"enabled": True,
"priority": 1,
"credentials": {}
}
}
}This platform app has the test provider enabled by default, which means internal testing can happen without configuring real payment providers. It also serves as the "owner" for platform-level operations that do not belong to any user's application.
Seed Data Script
A payment platform with an empty database is useless for development and testing. We built a seed data script that populates the database with realistic test data:
python# seed/seed_data.py
async def seed_database():
"""Populate database with test data for development."""
# Create platform user and app
await create_platform_user()
await create_platform_app()
# Create test users with different roles
test_users = [
{"email": "[email protected]", "role": "superadmin", "name": "Thales"},
{"email": "[email protected]", "role": "manager", "name": "Test Manager"},
{"email": "[email protected]", "role": "auditor", "name": "Test Auditor"},
]
for user_data in test_users:
await create_user(user_data)
# Create test applications
await create_test_apps(count=5)
# Create test transactions across different statuses
await create_test_transactions(count=200)
# Create sample coupons
await create_sample_coupons()The seed script creates users at each role level, multiple test applications with different provider configurations, and 200 transactions in various states (pending, completed, failed, refunded). This makes it possible to develop and test the admin panel without processing real payments.
The 13-Phase Admin Expansion Plan
The MVP was deliberately minimal. We shipped seven endpoints knowing there were at least thirteen more phases planned:
| Phase | Feature | Priority |
|---|---|---|
| 1 | Real-time transaction monitoring | High |
| 2 | User suspension and ban workflows | High |
| 3 | Provider health dashboard | High |
| 4 | Fee override per application | Medium |
| 5 | Audit log for all admin actions | High |
| 6 | Bulk transaction export (CSV/PDF) | Medium |
| 7 | Webhook delivery monitoring | Medium |
| 8 | Application approval workflow | Medium |
| 9 | Financial reconciliation tools | High |
| 10 | Multi-admin notification system | Low |
| 11 | Custom report builder | Low |
| 12 | Provider credential rotation alerts | Medium |
| 13 | Compliance and KYC review tools | High |
Some of these phases were implemented in subsequent sessions. The provider health dashboard, audit logging, and compliance tools became especially important as the platform moved toward production.
Design Decisions and Their Rationale
Why UUID routes instead of IP whitelisting? IP whitelisting breaks when you are building from Abidjan. Internet connections change, VPNs rotate IPs, and mobile tethering assigns new addresses constantly. A UUID route works from any IP.
Why 8-hour token expiry? A compromise between security and usability. Short enough that a stolen token has limited value. Long enough that you do not need to re-authenticate during a working session. We considered 1-hour tokens with refresh, but the added complexity was not justified for a single-admin setup.
Why numeric role levels instead of permission sets? For a four-role system, a linear hierarchy is sufficient and much simpler to reason about. If we needed fine-grained permissions (e.g., "can manage coupons but not users"), we would switch to a permission-set model. For now, the hierarchy serves perfectly.
Why a platform app? Without it, platform-level operations would need special-case code throughout the system. By giving the platform its own "user" and "app," every operation -- including internal ones -- follows the same code paths.
What We Learned
Building an admin panel for a fintech platform is different from building one for a blog or an e-commerce store. The stakes are higher. Every action in the admin panel involves real money, real businesses, and real regulatory obligations. The read-only auditor role exists not because we thought it would be cool, but because financial regulations require audit trails and separation of duties.
The UUID-based route was a pragmatic decision. It costs nothing to implement, breaks no existing patterns, and eliminates an entire class of automated attacks. Combined with proper authentication and role-based access control, it creates a defense-in-depth approach that we were comfortable with for an MVP.
In the next article, we cover how this custom admin panel was eventually replaced by SQLAdmin -- a migration that simplified our codebase while preserving all the security properties we built here.
This article is part of the "How We Built 0fee.dev" series. 0fee.dev is a payment orchestrator covering 53+ providers across 200+ countries, built by Juste A. GNIMAVO and Claude from Abidjan with zero human engineers. Follow the series for the complete build story.