When I inherited the Public Financial Management (PFM) project at EY, I quickly discovered that the authentication system was one of the biggest security risks. The previous team had implemented a custom authentication solution that had several critical flaws—and this was for a system handling government financial data.
Not ideal.
The Problem
The existing auth system had issues that would make any security professional cringe:
When I ran a security scan, we had over 1,000 vulnerabilities. For a system that would handle millions of dollars in international aid disbursement, this was unacceptable.
Why Auth0?
After evaluating several options—building a custom solution with Passport.js, implementing Keycloak, or using a managed service—we chose Auth0 for several reasons:
The licensing cost was significant, but the alternative—building and maintaining a secure auth system ourselves—would have cost far more in engineering time and potential security incidents.
The Architecture Challenge
Here's where it got interesting. We weren't just replacing a basic auth system—we needed to integrate Auth0 with our existing blockchain-based user management. Every user had an associated blockchain address, and their permissions were stored on-chain.
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ React │────▶│ Auth0 │────▶│ User API │
│ Frontend │ │ │ │ (Node.js) │
└─────────────┘ └─────────────┘ └─────────────┘
│ │
▼ ▼
┌─────────────┐ ┌─────────────┐
│ Custom │ │ Blockchain │
│ Actions │────▶│ (Geth) │
└─────────────┘ └─────────────┘The flow needed to:
Implementation: Custom Auth0 Actions
Auth0 Actions let you run custom code at specific points in the authentication pipeline. We used the Post Login action to inject blockchain data into the JWT:
exports.onExecutePostLogin = async (event, api) => {
const { user } = event;
// Fetch user's blockchain address from our API
const blockchainData = await fetch(
${process.env.API_URL}/users/${user.user_id}/blockchain,
{
headers: {
Authorization: Bearer ${process.env.API_TOKEN}
}
}
).then(r => r.json());
// Add custom claims to the ID token
api.idToken.setCustomClaim('blockchain_address', blockchainData.address);
api.idToken.setCustomClaim('organization_id', blockchainData.orgId);
api.idToken.setCustomClaim('permissions', blockchainData.permissions);
// Also add to access token for API authorization
api.accessToken.setCustomClaim('blockchain_address', blockchainData.address);
api.accessToken.setCustomClaim('permissions', blockchainData.permissions);
};Role-Based Access Control
Our RBAC system needed to map Auth0 roles to blockchain-based permissions. Government financial systems have complex permission structures—a Finance Officer can create transactions, but only a Director can approve ones over a certain amount.
interface UserPermissions {
canCreateTransactions: boolean;
canApproveTransactions: boolean;
canViewReports: boolean;
maxApprovalAmount: number;
organizationScope: string[];
}const mapRolesToPermissions = (roles: string[]): UserPermissions => {
const basePermissions: UserPermissions = {
canCreateTransactions: false,
canApproveTransactions: false,
canViewReports: false,
maxApprovalAmount: 0,
organizationScope: [],
};
for (const role of roles) {
switch (role) {
case 'finance-officer':
basePermissions.canCreateTransactions = true;
basePermissions.canViewReports = true;
break;
case 'director':
basePermissions.canApproveTransactions = true;
basePermissions.maxApprovalAmount = 100000;
break;
case 'minister':
basePermissions.canApproveTransactions = true;
basePermissions.maxApprovalAmount = Infinity;
break;
}
}
return basePermissions;
};
The key insight was that permissions lived in two places: Auth0 (for application access) and the blockchain (for transaction signing). We needed both to align.
The Migration Strategy
You can't just flip a switch when replacing authentication. We ran both systems in parallel for two weeks:
Results
After the migration, the improvements were dramatic:
The system successfully passed the Ministry's security audit, enabling the $1M+ project handover to the Government of Guinea-Bissau.
Lessons Learned
1. Don't build auth yourself
Unless you have a dedicated security team and compliance requirements that prevent using third-party services, use a proven solution. The cost of Auth0/Clerk/etc. is a fraction of building and maintaining secure auth.
2. Plan migrations carefully
Running systems in parallel catches issues before they affect all users. The extra infrastructure cost is worth it.
3. Custom claims are powerful but dangerous
They let you encode business logic in your tokens, but they also increase token size and can leak information. Be thoughtful about what you include.
4. Test edge cases obsessively
Password resets, account lockouts, token refresh flows, session expiration—these edge cases are where security vulnerabilities hide.
What's Next
I'm now applying these lessons to CROW, my automotive SaaS project. For an early-stage startup, Auth0 is overkill, so I'm implementing a simpler solution with NextAuth.js—but the principles remain the same: never roll your own crypto, validate everything, and assume breach.
Have questions about authentication architecture? Get in touch—I'm always happy to chat about security.