Skip to content
Mohammed Kharbotli

~/blog/why-i-shift-security-decisions-left-not-just-security-checks

Why I Shift Security Decisions Left, Not Just Security Checks

Shifting security left means more than running scanners earlier. It means making authorization, data-flow, and execution decisions early enough to shape the design before they become expensive to change.

I like a green pipeline.

I just become suspicious when everyone starts treating it as a character reference for the application.

The dependency scan passed. Static analysis found nothing blocking. No secrets were committed. Excellent.

Now, can a user export another customer’s invoices?

The pipeline may have no opinion on that. We still need one.

This is where “shift left” becomes interesting to me. Running checks earlier is useful. I also want the awkward security questions to arrive while the feature is still easy to change.

Who is allowed to request this operation? Whose authority executes it? What happens if that authority changes halfway through?

Those questions can alter the design. I would rather discuss them before the pull request contains twenty-seven files and everyone has become emotionally attached to the implementation.

The ticket usually sounds innocent

Take a familiar requirement:

Users should be able to export invoices and receive a download link.

Reasonable. Useful. Probably already promised to a customer.

My first questions are fairly ordinary:

Which invoices? Which fields? Can any account member export them? Can someone choose a different recipient? What happens if we remove their access while the export is queued?

That last question tends to slow the conversation down.

Good. We have found a decision.

If we skip it, somebody will eventually make the decision inside a handler, perhaps through this highly sophisticated authorization mechanism:

// Already checked in the controller.

I give comments like that very little credit. The controller checked something, at some point, using some state. The worker is running later with different credentials.

I want to know which parts of that earlier decision still apply.

I try to settle the authority before designing the message

This message looks convenient:

new ExportInvoices(
    invoiceIds: $input->invoiceIds,
    destinationEmail: $input->email,
);

It also gives the caller considerable influence over what the worker reads and where the result goes.

If the worker can read every tenant’s invoices, the implementation needs to prevent a user from borrowing that access. An authenticated request is not a blank cheque for everything the worker can technically do.

I would usually start with a controlled request record:

$export = $exports->request(
    tenantId: $context->tenantId(),
    requestedBy: $context->actorId(),
    invoiceIds: $validatedSelection,
);
 
$bus->dispatch(new GenerateInvoiceExport(
    exportId: $export->id(),
));

These are illustrative interfaces; reliable dispatch needs its own persistence arrangement.

The record gives us somewhere to bind the requester, tenant, selection, and execution policy. The worker still has to enforce those rules. An export ID is not a permission slip.

At this point, we can decide whether the job requires current user access when it runs or continues under an explicitly accepted organizational instruction.

Either can be appropriate. Accidentally choosing one because the worker has an administrator-level database connection is harder to defend.

“Make it secure” is not an acceptance criterion

I want requirements that another engineer can test without reading my mind.

For this export, I might write:

  • Every selected invoice must be within the requester’s authorized scope.
  • Losing access prevents a pending user-owned export from being released.
  • Downloads require current permission.
  • A selection containing a foreign invoice fails without generating an artifact.
  • Repeated requests cannot bypass the tenant’s export limits.

Now we have something to review.

We might disagree about the revocation rule. Finance may need an organization-owned export to complete after the employee who requested it leaves. That is a useful disagreement to have during design.

OWASP’s secure product design guidance calls for explicit security decisions during product design. In practice, I want those decisions written where the people implementing the feature will actually find them.

A short note in the feature’s design record is often more valuable than a beautiful document nobody remembers exists.

My first threat model is usually a conversation

For a bounded feature, I start by following the data.

The user selects records. The API stores a request. A worker reads the data. A file goes into storage. Someone downloads it.

Then I change one assumption at a time.

What if an invoice ID belongs to another tenant?

What if access is revoked?

What if the destination changes while the job is waiting?

What if support clicks Retry?

That last button deserves its own design review. It often has more production authority than its size suggests.

This is a practical starting point for threat modeling: identify the components, data flows, trust boundaries, and ways the operation could be misused. More sensitive changes deserve deeper analysis and specialist involvement.

I want a few concrete outputs: a decision, an owner, an enforcement point, and evidence that it works.

If the discussion cannot change the implementation or its tests, I question what we accomplished.

Then I make the intended boundary easier to use

Consider this repository call:

$invoices = $repository->findByIds($invoiceIds);

Every caller now needs to remember the appropriate access checks.

For the export operation, I prefer a contract that makes scope visible:

$invoices = $repository->getAllExportable(
    scope: $authorizedScope,
    invoiceIds: $invoiceIds,
);

The implementation must enforce the scope and reject an incomplete authorized selection. Naming a method confidently does not make it secure. Otherwise, definitelySafeExport() would have solved this industry years ago.

The point is to give the rule a clear home.

I apply the same thinking to shared infrastructure. If developers need private storage, narrow deployment credentials, or a reliable authorization mechanism, those capabilities should be available and documented.

“Shift left” gets rather unconvincing when it means handing developers more responsibility without the tools or authority to act on it.

Product owns decisions about permitted behavior. Platform engineering owns parts of the execution environment. Security contributes threat analysis and review. Application engineers turn those decisions into enforceable behavior.

The responsibilities need to meet somewhere.

I still want the scanners

Dependency scanning, secret detection, static analysis, and infrastructure checks all have a place in the pipeline.

I want them alongside tests for the application’s actual rules.

For example:

public function testMixedTenantSelectionCreatesNoArtifact(): void
{
    $request = $this->fixtures->exportRequest(
        requester: $this->userFromTenantA,
        invoices: [
            $this->invoiceFromTenantA,
            $this->invoiceFromTenantB,
        ],
    );
 
    try {
        $this->handler->handle($request);
        self::fail('Expected scope validation to fail.');
    } catch (ExportScopeViolation $e) {
        // Expected.
    }
 
    self::assertSame(0, $this->artifactStore->createdCount());
    self::assertSame(0, $this->deliveryGateway->sentCount());
}

The interfaces are illustrative.

Those final assertions matter. An exception after the file has already been delivered is a very accurate description of a failure, but a poor security control.

I also inspect what the test actually exercises. Mocking an authorization service to deny access proves little about whether the real query enforces tenant scope.

The check should reach the boundary whose behavior we are claiming to verify.

A finding needs somewhere to go

Adding a scanner is often easier than agreeing who will resolve its findings.

For blocking issues, I want a clear explanation and a practical remediation path. For findings requiring investigation, I want enough context to assess exposure and consequence.

Sometimes the answer is an immediate fix. Sometimes it is a temporary control with an owner and a review date.

“Accepted risk” without an owner has a habit of becoming permanent architecture.

I would rather have a manageable set of findings that receives serious attention than a dashboard with impressive numbers and a team trained to ignore it.

The feedback has to help someone make a decision.

Production still gets a vote

We can make good decisions early and still deploy the wrong permissions.

A storage policy changes. A new integration creates another data exit. A worker starts using broader credentials. A deployment bypasses a control that worked in testing.

I want runtime evidence for the assumptions that matter: authorization failures, unusual export volume, artifact retention, and administrative recovery actions.

I also want a way to stop the feature when necessary.

If we discover a missing boundary in production, that finding should improve the design, the shared tooling, or the tests. It should not disappear when the incident channel goes quiet.

Before implementation, I keep coming back to three questions:

Who may request this? What authority will carry it out? What must still be true when the effect happens?

Once we have answered those, the pipeline has something meaningful to verify.

And I can enjoy the green ticks without asking them to do the security architecture.