What Building Software for Regulated Finance Teaches You About Security (That General SaaS Work Doesn't)
I've spent the last several months building a multi-tenant member portal for a regulated finance sector. I can't name the product in this post, but I can talk about what the build has taught me about taking security seriously from the first commit rather than retrofitting it later.

Most Laravel projects I've worked on over the years have had a perfectly reasonable approach to security. Use the framework's defaults, hash your passwords, validate your input, scope your queries, keep your dependencies up to date. That gets you a long way. But when you're building software where the users are members of a financial institution, and their data sits next to other institutions' data, "reasonable defaults" stops being the bar. You have to be able to defend every decision, not just the obvious ones.
This post is a collection of the things I think are worth doing on every serious project, not just the regulated ones. They are not particularly exotic. Most of them are well-documented in the Laravel ecosystem already. What's interesting is how they fit together.
Separating identity from profile
The biggest architectural decision on the project was splitting authentication credentials from member profile data into two physically separate databases.
In a typical Laravel app, your users table holds everything: email, password, name, phone, address, the lot. That's fine for most applications. But when a single person is a member at multiple institutions, and each institution is legally responsible for their own copy of that person's data, putting it all in one table creates problems. Whose record is canonical? What if the member goes by a different name at one institution than another? What happens when one of them wants to delete their data and the others don't?
What we ended up with is a central record that holds only login credentials (email, password, two-factor secret, last active tenant) and a per-tenant users table that holds everything else (name, phone, address, date of birth, notification preferences). The two are linked through a join table on the central database that knows which member is registered at which institution.
The rule I keep coming back to is: everything except the credentials needed to sign in is per-tenant. If you find yourself wanting to share a piece of data across tenants, ask whether you really need to, or whether you've just defaulted to it because that's how most apps work.
This pattern has knock-on effects. Cross-database operations have to go through a coordinated service so the rest of the codebase isn't peppered with explicit connection switching. When a member updates their email, that fans out to every linked tenant because email is the shared login handle. Everything else stays where it is. It sounds like extra work, and it is, but the alternative is one big table that becomes harder to reason about every time a new regulatory requirement lands.
The audit log is not optional
Every sensitive action against a member's record creates a row in an audit_logs table on the tenant database. Sign-ins, profile changes, payments, staff actions on disclosures, account closures. Each row records who did the action, what they did, when they did it, the IP address they did it from, and a JSON Before/After diff of any fields that changed.
The convention I've settled on is dot-separated action names: auth.member.login, payment.completed, tenant_admin.feature_toggled. The Before/After diffs get rendered as a field-level diff in the admin UI, which means a staff member can open any audit row and see exactly what changed without having to interpret raw JSON.
This is one of those things that costs almost nothing to build at the start of a project and is genuinely painful to retrofit later. The model observers are simple. The service methods that perform sensitive actions write to the audit log as a final step inside the same database transaction. The only ongoing cost is remembering to log new sensitive actions as you add them, which is a code-review concern more than an engineering one.
The thing I didn't appreciate until we'd been running it for a while is how much it changes the conversation when something goes wrong. Instead of asking "what happened?", you can say "here's exactly what happened". That's the difference between a stressful incident review and a useful one.
Encrypting at the column level, not just the connection
TLS between your app and your database is table stakes. What it doesn't protect you from is a snapshot of your database ending up somewhere it shouldn't, or an operator with read access to tables they shouldn't be reading.
On this project, anything that's sensitive but doesn't need to be queryable gets encrypted at the column level using Laravel's encrypted:array cast. The integration credentials for each tenant (core-banking config, payment provider config) are encrypted. The free-text content of vulnerability disclosures and the staff notes on those disclosures are encrypted.
The trade-off is real. You can't query an encrypted column. You can't search for a vulnerability disclosure by text content. That's a constraint, but for this kind of data it's the right constraint. The point of the encryption is that the people who can read that data are the people sitting in front of the admin console, authenticated as a staff member with the right permission. Not the developer running an ad-hoc SQL query, not the DBA, not whoever ends up with a backup file on a laptop.
If you find yourself wanting to encrypt a column and also wanting to query it, that's usually a signal that you're storing the wrong thing or asking the wrong question. Either the column shouldn't be encrypted (because the searchability is core to the feature), or the query shouldn't exist (because that data shouldn't be casually accessible).
URLs that don't leak structure
One of the simpler things, but worth mentioning. Member-visible URLs in this app never expose auto-incrementing integer IDs. Account URLs use a ULID. Member URLs use either the institution's own membership number (where one exists) or a Crockford base32 reference generated from a cryptographically secure random source.
The reason is partly about not leaking how many accounts or members exist (a member who notices their reference is 47 knows something about the size of the institution they shouldn't), and partly about not encouraging URL-tampering as a method of testing access control. If the next URL up isn't predictable, you're not relying on hope.
We did try timestamp-derived ULIDs for member references initially, but they collided in burst inserts when a batch of members were registered together. The lesson there is that "almost unique" is not unique. We switched to CSPRNG-generated references and the problem went away.
Admin URLs still keep integer IDs because they're internal and never shared externally, but anything a member can see in their browser bar is opaque.
Treating payments as someone else's problem
Card payments on the portal are handled by Stripe Checkout. The member's card number never touches our application, our servers, or our database. They click pay, they go to Stripe's hosted form, they come back. We get a payment intent we can reconcile.
This puts the portal in PCI scope SAQ A, which is the lightest possible PCI compliance posture. It's not zero work, but it's a tiny fraction of what you'd be signing up for if you were processing card data yourself. And critically, the risk of a card data leak is borne by Stripe, who are very good at not leaking card data, rather than by us.
There's a broader principle here that I think gets overlooked. For most pieces of regulated functionality, there's a specialist provider whose entire business is doing that one thing well and absorbing the regulatory exposure that comes with it. Use them. Stripe for cards. Brevo or Twilio for SMS. A managed database service for the database. An identity verification specialist for KYC. Your application's job is to integrate these services thoughtfully and own the parts that are genuinely unique to your problem.
The temptation to build everything yourself is strong, especially when you can see how it would work. Resist it. The cost of doing it yourself is not just the engineering time; it's the ongoing compliance, monitoring, and incident response that comes with being responsible for a regulated capability.
Supply chain matters more than you think
This one took me a while to take seriously. Every CI run on the project generates a CycloneDX software bill of materials covering both the composer and npm dependency trees. Every commit to main is cryptographically signed (we use Sigstore's gitsign, which is keyless and GitHub OIDC backed; GPG is also supported). Tagged production releases mint a SLSA L2 provenance attestation that anyone can verify with the slsa-verifier tool.
If you've not come across SBOMs before: they are a machine-readable list of every dependency in your application, including transitive ones, with versions, hashes, and licence information. They cost nothing to generate (composer and npm both have plugins that produce them) and they mean that when a vulnerability is announced in a package you've never directly heard of, you can answer "are we affected?" in seconds instead of hours.
Signed commits and provenance attestations are about a different threat: someone slipping unauthorised code into a release. If every commit on main has to be signed, and every production deploy ships with an attestation that says "this artifact was built from this commit by this CI runner", then the surface area for that kind of attack shrinks considerably.
None of this is exotic any more. The tooling is good, the documentation is good, and the CI cost is negligible. The reason to do it is that supply chain attacks are now one of the most common ways serious breaches start. Solarwinds was a supply chain attack. Codecov was a supply chain attack. Putting these controls in place on a new project takes an afternoon.
A few things I'd do differently
Not everything has gone smoothly. A handful of things in hindsight:
CSP headers are harder than they look in a Livewire app. A nonce-only Content Security Policy would be ideal, but mixing a nonce with
'unsafe-inline'makes browsers drop the fallback and break Alpine and Livewire dynamic injection. We currently ship'self' 'unsafe-inline'and have a note to re-enable nonce-only the moment both libraries propagate nonces on every runtime injection.Cross-database transactions need a coordinator. Trying to keep two databases consistent without a service layer that owns the dance is a fast way to corrupt your data. Build the coordinator early.
Test the cross-database paths end-to-end. Mocking the identity service in tests is almost always the wrong call, because the thing you're trying to test is precisely the cross-database behaviour. Use a real service and let the tests touch both databases.
My honest take
Most of what's in this post isn't novel. Almost all of it is well-documented in the Laravel docs, the OWASP guides, or the relevant standards bodies' publications. What I've found is that the difficulty isn't knowing what to do; it's deciding to do it from day one rather than telling yourself you'll add it later.
You don't add it later. Or rather, you do, and it costs ten times what it would have cost upfront. Audit logging that's bolted on after launch misses half the actions that should be logged. Column-level encryption added to a populated table is a data migration and a feature freeze. URL keying retrofitted onto a live site strands every existing bookmark.
If you're starting a new project where the data has any real sensitivity, I'd encourage you to spend the first sprint or two getting these foundations right. The audit log, the encryption, the SBOM, the signed commits, the URL keying, the separation of identity from profile if it applies. Once they're in, they're cheap to live with. Getting them in afterwards is where projects go sideways.
The good news is that the Laravel ecosystem has matured to the point where almost none of this requires custom work. The casts, the encryption, the queue infrastructure, the testing helpers, they're all there. You just have to decide to use them.