DEV Community

Cover image for hash_equals('', '') Is true: When an Empty Config Opens Basic Auth
Ivan Mykhavko
Ivan Mykhavko

Posted on

hash_equals('', '') Is true: When an Empty Config Opens Basic Auth

I wasn't hunting for this. I was reading a Basic Auth middleware in a project I work on, checking something unrelated, and I stopped on one line. The comparison used hash_equals(), which is the right function. The credentials came from config, which is the right place. And if that config was empty, the middleware let anyone in.

The Problem

Here's the shape of it. A small internal API, protected by hand-rolled HTTP Basic Auth instead of the full auth stack, because the caller is another service and there's no user to log in:

<?php

declare(strict_types=1);

namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;

final class EnsureBasicAuth
{
    public function handle(Request $request, Closure $next): Response
    {
        $expectedUsername = config('services.internal_api.username');
        $expectedPassword = config('services.internal_api.password');

        $usernameOk = hash_equals($expectedUsername, (string) $request->getUser());
        $passwordOk = hash_equals($expectedPassword, (string) $request->getPassword());

        if (! $usernameOk || ! $passwordOk) {
            return response('Unauthorized', Response::HTTP_UNAUTHORIZED);
        }

        return $next($request);
    }
}
Enter fullscreen mode Exit fullscreen mode

Watch the method name. In a Laravel codebase $request->getUser() isn't $request->user(). It's Symfony's, and it returns the Basic Auth username as a string, or null when there's no Authorization header at all.

Nothing in that class is a typo. hash_equals() is the function everyone tells you to use, and the user input sits in the second argument, which is the correct order.

Now put a route behind it and send three kinds of request at it, while config/services.php reads those two values from the environment. Both credentials move together here, because they come from the same .env block and a fresh copy of .env.example leaves both blank:

Both credentials No Authorization header Basic Og== (blank user, blank password) admin + wrong password
empty strings 200 200 401
missing entirely 500 500 500
admin / s3cret 401 401 401

Look at the top row. An empty credential doesn't lock the door, it removes it, and a request carrying nothing at all gets a 200. Meanwhile the check most of us actually run, "let me try a wrong password", returns a confident 401 and reports that the guard works.

Why hash_equals Says Yes

Three small things line up.

hash_equals('', '') returns true, and that's not a bug. The function compares two strings and two empty strings are equal. What it protects is the timing of the comparison, so an attacker can't learn the secret byte by byte. (It leaks a little even there: a length mismatch returns early, so the length of your secret is visible in the timing. The manual says so.) None of that has anything to do with whether your secret exists.

A request with no Authorization header gives you null from getUser(), and the (string) cast turns null into ''. So an attacker doesn't even need to send Basic Og==. Sending nothing produces the same empty string.

And the env() default never fires. The Laravel docs are exact about this: the second argument to env() "will be returned if no environment variable exists for the given key". A bare INTERNAL_API_USER= line means the key does exist, holding an empty string, so env('INTERNAL_API_USER', 'fallback') hands you '' and not 'fallback'.

That's the state a box is in right after someone copies .env.example over and hasn't filled it in yet.

The 500 Is the Warning, Not the Bug

Now the middle row. Drop those two lines out of .env completely and config() returns null, so hash_equals() gets a null and throws:

TypeError: hash_equals(): Argument #1 ($known_string) must be of type string, null given
Enter fullscreen mode Exit fullscreen mode

Heads up: declare(strict_types=1) has nothing to do with this. hash_equals() checks its argument types itself rather than letting PHP coerce them, so it rejects every non-string in both weak and strict mode, and it names the type it got:

hash_equals(): Argument #1 ($known_string) must be of type string, int given
hash_equals(): Argument #1 ($known_string) must be of type string, true given
hash_equals(): Argument #1 ($known_string) must be of type string, array given
Enter fullscreen mode Exit fullscreen mode

A 500 is an ugly way to refuse a request, but it does refuse it. And that's the trap, because the obvious way to make the noise stop is a cast on the expected value:

// Don't. This is the whole vulnerability in two characters.
$expectedUsername = (string) config('services.internal_api.username');
$expectedPassword = (string) config('services.internal_api.password');
Enter fullscreen mode Exit fullscreen mode

Now null becomes '' before it ever reaches hash_equals(), the exception is gone, and the middle row of that table turns from 500 into 200. Someone tidying up their error tracker just opened the endpoint to the internet.

The Fix

Refuse to compare at all when there's nothing to compare against. Two guards, both above the hash_equals() calls:

public function handle(Request $request, Closure $next): Response
{
    if (! $this->credentialsMatch($request)) {
        return response('Unauthorized', Response::HTTP_UNAUTHORIZED, [
            'WWW-Authenticate' => 'Basic realm="Internal"',
        ]);
    }

    return $next($request);
}

private function credentialsMatch(Request $request): bool
{
    $expectedUsername = config('services.internal_api.username');
    $expectedPassword = config('services.internal_api.password');

    if (! is_string($expectedUsername) || ! is_string($expectedPassword)) {
        return false;
    }

    if ($expectedUsername === '' || $expectedPassword === '') {
        return false;
    }

    $usernameOk = hash_equals($expectedUsername, (string) $request->getUser());
    $passwordOk = hash_equals($expectedPassword, (string) $request->getPassword());

    return $usernameOk && $passwordOk;
}
Enter fullscreen mode Exit fullscreen mode

Both hash_equals() calls run before the && sees anything, so neither comparison gets skipped and the timing stays flat. The WWW-Authenticate header is a small bonus fix: a bare 401 without it isn't valid HTTP.

Three things I'd add around it in production, none of which belong inside the middleware:

  • Validate the config at deploy time. A misconfigured guard returns 401 to everybody, which is safe but completely silent. You find out when the other service starts alerting. A boot-time assertion, or a .env validation step in the pipeline, tells you first.
  • Keep it behind TLS and a throttle. Basic Auth is base64, not encryption, and nothing here slows down someone guessing passwords. ->middleware('throttle:10,1') costs nothing.
  • Read config, never env(), inside a middleware. Turns out this is a second trap: after php artisan config:cache the .env file isn't loaded at all, and env() returns only real system-level environment variables. A value that lives only in .env comes back null on a cached deploy, which drops you straight into the middle row of that table.

The Test

The test that catches this doesn't test the password. It tests the config:

#[Test]
public function it_rejects_everyone_when_the_credentials_are_empty(): void
{
    // Arrange
    config([
        'services.internal_api.username' => '',
        'services.internal_api.password' => '',
    ]);

    // Act & Assert
    $this->get('/internal/health')->assertUnauthorized();
    $this->withBasicAuth('', '')->get('/internal/health')->assertUnauthorized();
}
Enter fullscreen mode Exit fullscreen mode

Add a second one setting both values to null, and a third for the happy path. Against the original middleware, the first two go red and say exactly what the table said:

✓ it allows the configured credentials
⨯ it rejects everyone when the credentials are empty
  Expected response status code [401] but received 200.
⨯ it rejects everyone when the credentials are missing
  Expected response status code [401] but received 500.
Enter fullscreen mode Exit fullscreen mode

Notice which one passed. The happy-path test is green on the broken middleware and green on the fixed one, so a suite that only checks "right credentials in, wrong credentials out" never had a chance here.

TL;DR

  • hash_equals('', '') is true. Constant-time comparison guards the timing of a comparison, not the existence of a secret.
  • INTERNAL_API_USER= isn't a missing variable, it's an empty string, and the env() default doesn't fire for it.
  • No Authorization header means getUser() is null, and one (string) cast on the expected side makes null match it.
  • If a misconfigured guard throws, don't cast the error away. Put an "is this configured at all" branch above every credential comparison.
  • Test the unconfigured states. Wrong-password tests pass on broken code.

💡 If your callers are real users in a database, skip all of this and use Laravel's own auth.basic, which can't get into an empty-secret state. Just know it wants a users table, keys on email by default, and starts a session, so it's a poor fit for service-to-service calls.



Author's Note

Thanks for sticking around!
Find me on dev.to, linkedin, or you can check out my work on github.

Laravel, after the happy path.

Top comments (0)