Brazil mandates that every business issuing goods or services emit an NF-e (Nota Fiscal Eletrônica) — a government-signed XML submitted to SEFAZ in real-time. The protocol is SOAP-based, from 2006, documented only in Portuguese, and the homologation environment goes offline on weekends.
I spent 6 months building FoxNFe — a multi-tenant SaaS that abstracts all of this into a simple REST API. Here is everything I learned the hard way.
The Architecture
Tenant App ──REST──► FoxNFe Laravel API ──SOAP/XML──► SEFAZ (27 states)
│
PostgreSQL RLS
Redis + Queues
Certificate Store
- Laravel 11 — API backend, queued jobs, service layer
- PostgreSQL + RLS — row-level security per tenant
- Redis + Laravel Queues — async processing (SEFAZ takes 2–30s per request)
- A1 Digital Certificates — encrypted per-tenant, loaded at runtime
- 27 SEFAZ endpoints — one per Brazilian state, routed by CNPJ UF
Lesson 1: PostgreSQL RLS finally Is Not Optional
Multi-tenant apps on PostgreSQL often use Row-Level Security with a session variable:
CREATE POLICY tenant_isolation ON tenant_plan_usages
USING (
tenant_id = current_setting('app.tenant_id')::int
OR current_setting('app.bypass_rls', true) = 'on'
);
For admin queries that need to cross tenant boundaries, you bypass RLS:
// DANGEROUS without finally
DB::statement("SELECT set_config('app.bypass_rls', 'on', false)");
$usage = TenantPlanUsage::withoutGlobalScopes()->where('tenant_id', $id)->first();
DB::statement("SELECT set_config('app.bypass_rls', '', false)");
The bug we shipped: an exception between the two set_config calls left bypass_rls = 'on' active for the entire database connection. Every subsequent query on that connection returned data from all tenants.
The fix:
public function getOrCreateUsage(Tenant $tenant): TenantPlanUsage
{
return DB::transaction(function () use ($tenant): TenantPlanUsage {
DB::statement("SELECT set_config('app.bypass_rls', 'on', false)");
try {
$usage = TenantPlanUsage::withoutGlobalScopes()
->where('tenant_id', $tenant->id)
->lockForUpdate()
->first();
if ($usage === null) {
$plan = $this->getActivePlan($tenant);
$quota = $plan->isEnterprise() ? null : $plan->monthly_note_quota;
$usage = TenantPlanUsage::forceCreate([
'tenant_id' => $tenant->id,
'plan_id' => $plan->id,
'nfes_used' => 0,
'period_start' => now()->startOfMonth()->toDateString(),
'period_end' => now()->endOfMonth()->toDateString(),
'monthly_quota' => $quota,
]);
}
return $usage;
} finally {
// Restore even if an exception fires
DB::statement("SELECT set_config('app.bypass_rls', '', false)");
}
});
}
Rule: Every bypass_rls on needs a finally. Code review should enforce this like a lint rule.
Lesson 2: A1 Certificates — Encrypt Everything, Write Nothing to Disk
Each Brazilian company has a digital A1 certificate (.pfx file, password-protected), issued by an ICP-Brasil authority. This certificate signs every NF-e XML. In multi-tenant, each tenant has their own.
What NOT to do: store certificates as files on disk named by CNPJ, or log the decrypted content anywhere.
What we do:
// Store — encrypt before saving to DB
public function storeCertificate(Tenant $tenant, UploadedFile $pfx, string $password): void
{
$tenant->update([
'certificate_content' => encrypt($pfx->get()),
'certificate_password' => encrypt($password),
'certificate_expires' => $this->extractExpiry($pfx->get(), $password),
]);
}
// Load at runtime — decrypt in memory only, never touch disk
public function loadCertificate(Tenant $tenant): array
{
$certData = [];
$success = openssl_pkcs12_read(
decrypt($tenant->certificate_content),
$certData,
decrypt($tenant->certificate_password)
);
if (!$success) {
throw new InvalidCertificateException(
"Certificate invalid or password wrong for tenant {$tenant->id}"
);
}
return $certData; // ['cert' => '...PEM...', 'pkey' => '...PEM...']
}
Additional safeguards:
-
encrypt()uses Laravel'sAPP_KEY(AES-256-CBC) — rotate keys carefully, plan for re-encryption - Index
certificate_expires— alert tenants 30 days before expiry, or their NF-e emissions stop cold - Never write the decrypted
.pfxto disk, not even/tmp
Lesson 3: SEFAZ cStat Codes — Every One Matters
SEFAZ returns XML with a cStat field. Most docs only mention 100. Here is the full map of what you will actually see in production:
| cStat | Meaning | Correct action |
|---|---|---|
| 100 | Authorized ✅ | Save XML, increment quota |
| 150 | Authorized (out-of-time window) | Same as 100 |
| 204 | Already authorized (duplicate) | Treat as 100 |
| 110 | Denied — data error | Fix XML, re-emit |
| 301 | Unauthorized usage (wrong UF endpoint) | Check state routing |
| 539 | Schema validation error | Your XML is malformed |
| 999 | SEFAZ internal error | Retry with exponential backoff |
cStat 204 is the trap. If your job times out and retries, SEFAZ may have already processed the NF-e. Treating 204 as an error causes double-counting in your quota and confuses the tenant. Always treat 204 as success.
private function processResponse(NFeResponse $response): void
{
match (true) {
in_array($response->cStat, [100, 150, 204]) => $this->handleAuthorized($response),
$response->cStat === 110 => $this->handleDenied($response),
$response->cStat === 999 => $this->handleRetry($response),
default => $this->handleUnknown($response),
};
}
Lesson 4: Quota Increment Must Happen AFTER Authorization
The billing bug we shipped: processResponse() saved the authorized NF-e but never called incrementNfeUsage(). Result: every tenant had infinite quota forever.
The fix — increment after, with a catch that logs but does not throw:
private function handleAuthorized(NFeResponse $response): void
{
// 1. Persist authorization first
$this->nfe->update([
'status' => NFeStatus::AUTHORIZED,
'protocol' => $response->nProt,
'authorized_at' => now(),
'xml_authorized'=> $response->xml,
]);
// 2. Increment quota — if this fails, the NF-e is already authorized
// in SEFAZ. We cannot reverse it. Log the failure and continue.
try {
$this->planEnforcer->incrementNfeUsage($this->tenant);
} catch (\Throwable $e) {
Log::warning('Quota increment failed after NF-e authorization', [
'nfe_id' => $this->nfe->id,
'tenant_id' => $this->tenant->id,
'error' => $e->getMessage(),
]);
}
}
Why catch instead of letting it propagate? The NF-e is already authorized in SEFAZ — it exists in the government's system. If we throw here, the job retries, SEFAZ returns 204 ("already authorized"), and we end up in an infinite loop. The tenant gets their authorized NF-e, and ops gets an alert to fix the quota manually.
Lesson 5: IDOR Hidden in Tenant Resolution
Our controllers originally resolved the current tenant from the X-Tenant-ID header:
// VULNERABLE
private function resolveTenant(Request $request): Tenant
{
return Tenant::findOrFail($request->header('X-Tenant-ID'));
}
// upgrade() used this — any authenticated user could upgrade ANY tenant's plan
public function upgrade(Request $request): JsonResponse
{
$tenant = $this->resolveTenant($request);
// ... proceed to upgrade billing
}
An attacker with a valid JWT for tenant A could send X-Tenant-ID: B and upgrade B's plan, downgrade it, or access its usage data.
The fix — one line, always verify ownership:
private function resolveTenant(Request $request): Tenant
{
$tenant = Tenant::findOrFail($request->header('X-Tenant-ID'));
if ($request->user()->tenant_id !== $tenant->id) {
abort(403, 'Unauthorized: tenant mismatch');
}
return $tenant;
}
Simple. But in multi-tenant systems where the tenant ID comes from the client, this check is easy to forget when you assume the JWT is sufficient authorization. It is not.
Lesson 6: Testing Without RefreshDatabase
Our staging PostgreSQL does not allow DROP TABLE — security policy. RefreshDatabase trait is out entirely. We use explicit setUp/tearDown with direct cleanup:
class PlanEnforcerTest extends TestCase
{
private ?Tenant $tenant = null;
protected function setUp(): void
{
parent::setUp();
if (!$this->isPgsqlAvailable()) {
$this->markTestSkipped('PostgreSQL not available');
return; // guard required — see tearDown note below
}
$this->tenant = Tenant::factory()->create();
}
protected function tearDown(): void
{
// CRITICAL: markTestSkipped() in setUp() does NOT prevent tearDown()
// from running. If $this->tenant is null (test was skipped),
// forceDelete() will throw. Always null-check.
if ($this->tenant !== null) {
TenantPlanUsage::where('tenant_id', $this->tenant->id)->forceDelete();
$this->tenant->forceDelete();
}
parent::tearDown();
}
public function test_rls_restored_after_exception(): void
{
// Simulate exception inside getOrCreateUsage
$this->mock(TenantPlanUsage::class)
->shouldReceive('forceCreate')
->andThrow(new \RuntimeException('Simulated DB failure'));
try {
app(PlanEnforcer::class)->getOrCreateUsage($this->tenant);
} catch (\Throwable) {
// Expected
}
$setting = DB::selectOne("SELECT current_setting('app.bypass_rls', true) AS val");
$this->assertNotEquals('on', $setting->val, 'bypass_rls must be restored after exception');
}
}
This pattern — factory in setUp, forceDelete in tearDown, null guard everywhere — works reliably with 11+ months of CI runs.
Bonus: Local XSD Validation Before Submission
SEFAZ cStat 539 (schema error) does not tell you which field is wrong. Debug cycle: submit → wait 5s → get 539 → guess what's wrong → repeat. Brutal.
Solution: validate against the official XSD locally before signing:
public function validateSchema(string $xml): void
{
$dom = new \DOMDocument();
$dom->loadXML($xml);
$xsdPath = storage_path('sefaz/nfe_v4.00.xsd');
if (!$dom->schemaValidate($xsdPath)) {
$errors = libxml_get_errors();
throw new SchemaValidationException(
'NF-e XML schema invalid: ' . $errors[0]->message . ' on line ' . $errors[0]->line
);
}
}
Run this before AND after signing. SEFAZ XSDs are available on the portal.nfe.fazenda.gov.br portal (buried 4 clicks deep).
What's Next
FoxNFe enters SEFAZ official homologation this week. After certification goes live:
- Multi-state routing for all 27 Brazilian states (UF auto-detection from CNPJ)
- NFS-e (service invoices) via municipal APIs — each city has its own protocol
- Webhook notifications on authorization, denial, and cancellation
- Dashboard with quota usage, certificate expiry countdown, and NF-e status timeline
If you are building fiscal integrations in Brazil, or have questions about SEFAZ, SOAP, or PHP certificate handling — drop a comment. Happy to go deeper on any of these.
25 years of development. Built with: Laravel 11 · PostgreSQL 16 · Redis · Docker · Claude Code (Anthropic)
🔗 foxnfe.centralfox.online | Reddit u/foxdigitaldev
Top comments (0)