DEV Community

Cover image for Secrets in Laravel — Why `.env` Is Only the Beginning

Secrets in Laravel — Why `.env` Is Only the Beginning

Originally published on Medium
Most developers think adding .env to .gitignore solves secret management. It doesn't.

This is the eleventh article in a series on PHP and Laravel application security.

So far we have covered:

  • Detecting SQL injection attempts in PHP logs
  • Why URL encoding blinds most PHP security checks
  • The decode bomb problem with unlimited URL decoding
  • Why parameterized queries are the only real fix for SQL injection
  • XSS prevention in Laravel and why {!! !!} is the line between safe and hacked
  • How attackers enumerate your Laravel app before exploiting it
  • File upload security — the file that isn't what it claims to be
  • Path traversal in PHP — how ../ escapes your application
  • Command injection in PHP — when exec() becomes an attack surface
  • Broken access control in Laravel — why being logged in is not enough

Every article in this series follows the same principle: understand the attack before you try to stop it.

Secrets are different from every other topic in this series. SQL injection, XSS, path traversal those are vulnerabilities in your code. Secret leakage is a vulnerability in your process. It happens not because you wrote something wrong but because you stored, logged, or committed something in the wrong place.

And unlike a code vulnerability that can be patched an exposed secret that has been harvested by an automated scanner cannot be un-exposed. The only fix is rotation.

What Secrets Actually Are

Not all configuration is equal. There is a meaningful difference between configuration and secrets.

Configuration controls how your application behaves:

APP_NAME=MyApp
APP_URL=https://myapp.com
MAIL_MAILER=smtp
DB_HOST=localhost
Enter fullscreen mode Exit fullscreen mode

Secrets grant access to systems, services, or data:

DB_PASSWORD=supersecretpassword
STRIPE_SECRET_KEY=sk_live_abc123
AWS_SECRET_ACCESS_KEY=wJalrXUtnFEMI
MAIL_PASSWORD=mypassword
APP_KEY=base64:abc123...
Enter fullscreen mode Exit fullscreen mode

Configuration can often be committed to version control. Secrets should never be. The distinction matters because secrets require fundamentally different handling different storage, different access controls, and a rotation plan for when they are compromised.

Most Laravel developers put both in .env and add .env to .gitignore. That solves one problem. It leaves five others untouched.

How Secrets Actually Leak

During internal testing of Kriosa, automated scanners were repeatedly observed probing for exposed .env files, backup archives, and forgotten configuration endpoints. Those reconnaissance requests happen long before an attacker attempts exploitation which is why secret exposure should be treated as both a prevention and a detection problem, not just a deployment concern.

Here is every vector through which secrets leave applications in practice.

Leak Vector 1 — Git History

A developer accidentally commits .env:

git add .
git commit -m "initial commit"
Enter fullscreen mode Exit fullscreen mode

They realize the mistake and delete the file:

git rm .env
git commit -m "remove env file"
Enter fullscreen mode Exit fullscreen mode

They think the problem is solved. It is not.

Git is designed to never lose history. The .env file and every secret it contained are permanently stored in the Git object database. Anyone with access to the repository can retrieve them:

git log --all --full-history -- .env
git show [commit-hash]:.env
Enter fullscreen mode Exit fullscreen mode

Every secret that was ever in that file is recoverable even after deletion — unless the entire Git history is rewritten with a tool like git filter-repo.

Automated tools scan public GitHub repositories continuously looking for accidentally committed secrets. Within minutes of a secret being pushed to a public repository it has likely been harvested.

What to do:

  • Add .env to .gitignore before the first commit not after
  • If secrets were committed use git filter-repo to rewrite history
  • Immediately rotate every secret that was ever in the committed file
  • Never assume deletion from Git removes the data

Leak Vector 2 — Log Files

Laravel logs requests, errors, and debug information. If your application logs user input or request data and many do secrets can end up in log files in plain text.

Common patterns that cause secret leakage into logs:

// Logs everything — including Authorization headers and API keys
Log::info('Incoming request', $request->all());

// Logs the full exception context — may include secrets in memory
Log::error('Payment failed', ['data' => $request->all()]);

// Logs API responses that may contain tokens
Log::debug('API response', ['response' => $apiResponse]);
Enter fullscreen mode Exit fullscreen mode

If a user submits a form with a field named api_key and you log $request->all() that key is now in your log file in plain text.

Laravel provides a way to prevent specific fields from appearing in logs through the exception handler:

// app/Exceptions/Handler.php — Laravel 10 and earlier
protected $dontFlash = [
    'current_password',
    'password',
    'password_confirmation',
];
Enter fullscreen mode Exit fullscreen mode

Laravel does not automatically hide custom fields like api_key, token, or stripe_key. If your application accepts them extend the $dontFlash array or explicitly avoid logging those values:

protected $dontFlash = [
    'current_password',
    'password',
    'password_confirmation',
    'api_key',
    'token',
    'secret',
    'stripe_key',
    'aws_key',
];
Enter fullscreen mode Exit fullscreen mode

Add every secret field name your application uses to this list.

Leak Vector 3 — Debug Mode and Error Pages

When APP_DEBUG=true in production, Laravel's error pages can expose sensitive configuration values, stack traces, file paths, and application internals. Depending on the error and configuration, this may include secrets or values derived from your environment.

APP_DEBUG=false  ← non-negotiable in production
APP_ENV=production
Enter fullscreen mode Exit fullscreen mode

Even with debug mode off, verbose error handling can expose secrets:

// Stack traces may contain secret values that were in memory at the point of failure
Log::error($exception->getMessage(), $exception->getTrace());
Enter fullscreen mode Exit fullscreen mode

Log the message. Be careful with the full trace when secrets may be in scope at the point of failure.

Leak Vector 4 — Hardcoded Secrets in Code

Developers sometimes hardcode secrets directly in code during development and forget to move them to environment variables before committing:

// Dangerous — committed to version control, visible to every developer
$stripe = new StripeClient('sk_live_abc123def456');

// Safe — loaded from config
$stripe = new StripeClient(config('services.stripe.secret'));
Enter fullscreen mode Exit fullscreen mode

Hardcoded secrets are committed to version control, stored in deployment artifacts, visible in code reviews, and accessible to every developer with repository access. They are also the hardest to rotate because you have to find every place the secret appears in the codebase.

Leak Vector 5 — Third-Party Logging Services

Many Laravel applications send logs to third-party services Papertrail, Loggly, Datadog, Sentry. If secrets appear in your application logs they appear in these services too with their own retention policies, access controls, and security posture that you do not fully control.

If you send logs to a third-party service audit what is in those logs. Secrets that appear there need to be rotated and the logging configuration needs to be fixed before the rotation has any meaningful effect.

Laravel-Specific Secret Management

The APP_KEY

Laravel's APP_KEY is the most sensitive secret in a Laravel application. It is used to encrypt cookies, session data, and model fields encrypted with Laravel's encryption.

If your APP_KEY is compromised an attacker can decrypt all encrypted session data, forge signed cookies, and decrypt any fields encrypted with Laravel's encryption. Rotate it immediately if it is ever exposed but be aware that rotating the APP_KEY invalidates all existing sessions and encrypted data.

# Generate a new APP_KEY
php artisan key:generate
Enter fullscreen mode Exit fullscreen mode

Using config() instead of env() directly

This is a Laravel best practice that most developers miss:

// Wrong — does not work correctly when config is cached
$key = env('STRIPE_SECRET_KEY');

// Correct — works with config caching
$key = config('services.stripe.secret');
Enter fullscreen mode Exit fullscreen mode

Once configuration is cached with php artisan config:cache, you should only call env() from configuration files. Application code should access configuration through config(), ensuring values continue to work correctly when configuration is cached.

Define secrets in config files:

// config/services.php
'stripe' => [
    'secret' => env('STRIPE_SECRET_KEY'),
    'webhook_secret' => env('STRIPE_WEBHOOK_SECRET'),
],
Enter fullscreen mode Exit fullscreen mode

Then access them through config() in your application code:

$key = config('services.stripe.secret');
Enter fullscreen mode Exit fullscreen mode

Laravel's encrypted .env

Recent versions of Laravel support encrypted environment files:

# Encrypt your .env file  safe to commit the encrypted version
php artisan env:encrypt

# Decrypt on the server using the encryption key
php artisan env:decrypt --key=[encryption-key]
Enter fullscreen mode Exit fullscreen mode

This allows you to commit an encrypted .env file to version control while keeping the decryption key separate. The decryption key itself must be stored and transmitted securely if it is compromised the encrypted file offers no protection.

Secret Rotation

Secret rotation means replacing a compromised or expired secret with a new one. Most developers never think about rotation until something goes wrong. By then the window of exposure may already be significant.

Rotate immediately when:

  • A secret was committed to a public repository
  • A developer with access to secrets leaves the team
  • A third-party service you use reports a breach
  • Your server was compromised
  • A secret appeared in logs that were accessed without authorization
  • You suspect any unauthorized access to a system that holds secrets

Laravel rotation checklist:

  • Generate a new value for the compromised secret
  • Update .env on every server
  • Update the secret in CI/CD pipelines and deployment configurations
  • Revoke the old secret at the source revoke the API key, change the database password, regenerate the APP_KEY
  • Verify the application works with the new secret
  • Audit logs for evidence of use of the compromised secret

The rotation problem most developers ignore:

You cannot rotate a secret you do not know exists. If secrets are scattered across codebases, hardcoded in old scripts, stored in developers' local .env files, and embedded in CI/CD configurations you cannot reliably rotate them when something goes wrong.

This is why centralization matters. Every secret in one place, with one rotation process, auditable and controllable.

Beyond .env — Secret Management for Production

For production applications .env files have real limitations. They are stored in plain text on the server. They are difficult to rotate across multiple servers simultaneously. They are not auditable. They are not versioned.

Options for Laravel in production:

AWS Secrets Manager:

$secret = Cache::remember(
    'db-secret',
    now()->addMinutes(10),
    function () use ($client) {
        $result = $client->getSecretValue([
            'SecretId' => 'myapp/production/db'
        ]);
        return json_decode($result['SecretString'], true);
    }
);
Enter fullscreen mode Exit fullscreen mode

Note the caching without it every request hits the AWS API, adding latency and cost. Cache secrets locally for a short period and refresh them periodically.

Laravel Forge:

Laravel Forge provides a convenient interface for managing environment variables during deployment, but those values are still stored on the server. Forge simplifies management it is not a dedicated secret manager.

GitHub Actions Secrets and GitLab CI/CD Variables:

For CI/CD pipelines use your platform's built-in secret management rather than storing secrets in pipeline configuration files or repository variables visible to all developers.

The Secrets Security Checklist

  • .env is in .gitignore before the first commit not after
  • No secrets are hardcoded anywhere in the codebase
  • APP_DEBUG=false in all production environments
  • $dontFlash in the exception handler covers every secret field name your application uses
  • Secrets are never logged every Log:: call has been reviewed
  • config() is used instead of env() directly in application code
  • Every developer who leaves the team triggers immediate secret rotation
  • Git history has been audited for accidentally committed secrets
  • A rotation plan exists and has been tested for every secret in the application
  • Production secrets are stored in a secret manager not a plain text .env file

Secret management is never about hiding a single .env file.

It is about controlling where secrets live, limiting who can access them, rotating them when necessary, and detecting when someone is trying to steal them.

In production, Kriosa monitors incoming request patterns for behavioral signals of secret probing — requests targeting .env files, configuration endpoints, backup files, and Git metadata that may expose secrets. When reconnaissance activity targeting your secrets starts, the XAI dashboard surfaces it with an explanation of what was attempted and why it was flagged.

Prevention reduces exposure. Detection reveals compromise. Mature security requires both.

Try it free: kriosa.com
Install it: composer require kriosa-ai/kriosa-php
Then add,

Then at the entry or index.php add at the top before your code runs.


<?php
// After Composer installation
require_once 'vendor/autoload.php';

$apiKey = getenv('KRIOSA_API_KEY') ?: 'YOUR_API_KEY_HERE';
try {
    $kriosa = new Kriosa($apiKey, [
        'timeout'     => 3,
        'debug'       => false,
        'fail_closed' => false,
        'show_badge'  => true,
    ]);
    if (!$kriosa->protect()) {
        header('X-Kriosa-Blocked: true');
        http_response_code(403);
        exit('Access Denied');
    }
} catch (Exception $e) {
    error_log('Kriosa Security Error: ' . $e->getMessage());
}
// Your application continues safely...

Enter fullscreen mode Exit fullscreen mode

See Documentation : Documentation
Built by a developer, for developers who want to understand their security — not just outsource it.
Sleep better we're awale - kriosa
The Series So Far

  • Article 1: What your PHP logs actually look like during a SQL injection attack
  • Article 2: Why URL encoding can break PHP security checks
  • Article 3: The decode bomb problem — why unlimited URL decoding can be its own vulnerability
  • Article 4: Parameterized queries — the only real fix for SQL injection
  • Article 5: XSS prevention in Laravel and why {!! !!} is the line between safe and hacked
  • Article 6: How attackers enumerate your Laravel app and what to hide
  • Article 7: File upload security in PHP and Laravel
  • Article 8: Path traversal in PHP — how ../ escapes your application
  • Article 9: Command injection in PHP — when exec() becomes an attack surface
  • Article 10: Broken access control in Laravel — why being logged in is not enough
  • Article 11: This article — secrets in Laravel and why .env is only the beginning

Top comments (0)