↓ Skip to main content

Symfony HTTP Basic authentication: a quick guide for Symfony 7.4 and 8

··4 mins
Hennadii Alforov
Author
Hennadii Alforov
Senior Backend Engineer with 15+ years in IT. I build and scale backend systems for high-traffic platforms - billing, payments, hosting and domain services.

To protect one Symfony endpoint with HTTP Basic authentication, give it its own firewall with http_basic, a memory user provider with a single user, and an access_control rule for the path. The rest of the app keeps its normal login. The config below is checked on Symfony 8.1 and 7.4 LTS.

I first wrote this post in 2022 for an endpoint that receives payment notifications. One external client, one login and password, no users table in the database. An IP firewall would be stricter, but that time I went with a password.

The Symfony docs have every piece. The catch is that their example adds http_basic to the main firewall, which covers the whole app. For one endpoint and one client you want a separate firewall.

The config
#

The password lives in an environment variable:

# .env.local, or a real environment variable in production
PAYMENT_WEBHOOK_PASSWORD=use-a-long-random-string-here
# config/packages/security.yaml
security:
    password_hashers:
        Symfony\Component\Security\Core\User\InMemoryUser: plaintext

    providers:
        payment_webhook:
            memory:
                users:
                    payments:
                        password: '%env(PAYMENT_WEBHOOK_PASSWORD)%'
                        roles: ['ROLE_PAYMENT_WEBHOOK']

    firewalls:
        dev:
            pattern: ^/(_profiler|_wdt|assets|build)/
            security: false
        payment_webhook:
            pattern: ^/api/v1/notify
            stateless: true
            provider: payment_webhook
            http_basic:
                realm: Payment notifications
        main:
            lazy: true
            # your usual provider and login stay here

    access_control:
        - { path: ^/api/v1/notify, roles: ROLE_PAYMENT_WEBHOOK }

Symfony uses the first firewall whose pattern matches, so payment_webhook has to sit above main. stateless: true means no session cookie. The client sends the credentials with every request anyway.

The firewall alone blocks nothing. Without the access_control line a request with no credentials goes straight to the controller. I checked: it got a 204. The rule is what makes the login required.

If main has its own login, point it at its own provider: as well. With two providers Symfony won't guess which one you meant.

Check it with curl
#

curl -i -X POST https://example.com/api/v1/notify
# HTTP/1.1 401 Unauthorized
# WWW-Authenticate: Basic realm="Payment notifications"

curl -i -X POST https://example.com/api/v1/notify \
  -u 'payments:use-a-long-random-string-here' \
  -H 'Content-Type: application/json' \
  -d '{"status":"paid"}'
# HTTP/1.1 204 No Content (that's what my controller returns)

-u builds the Authorization: Basic ... header for you: base64 of login:password. Base64 isn't encryption, so this only makes sense over HTTPS.

Plaintext or a hashed password?
#

With plaintext Symfony compares the password from the request with the value of the env variable as is. The alternative is the auto hasher and a hash in the variable:

password_hashers:
    Symfony\Component\Security\Core\User\InMemoryUser: auto
php bin/console security:hash-password 'your-password' 'Symfony\Component\Security\Core\User\InMemoryUser'

Two things caught me when I tried it on a fresh project.

It's slow on purpose. A stateless firewall checks the password on every request, and auto means bcrypt with cost 13. On my laptop in Docker one notification took about 430 ms with the hash and about 7 ms with plaintext.

And the hash is full of $. Inside double quotes in .env, Symfony's Dotenv treats $o... as a variable and replaces it with nothing. Whether that happens depends on the random salt. My first hash had a digit right after $13$ and worked, the next one had a letter there and failed. Use single quotes:

PAYMENT_WEBHOOK_PASSWORD_HASH='$2y$13$o.tcUFgfuZuJnvqDWDHU1ebyFbR38rpSW4.UyilLM7TRNTguMMerm'

For a webhook I keep plaintext. The password is a long random string that only two systems know, and if it leaks I rotate it. A password that a person types, and probably uses somewhere else too, should be hashed.

Test it with WebTestCase
#

loginUser() doesn't work with stateless firewalls, so pass the credentials the way PHP gets them from the header. Put PAYMENT_WEBHOOK_PASSWORD=test-secret into .env.test:

use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;

final class PaymentNotificationTest extends WebTestCase
{
    public function testRejectsRequestWithoutCredentials(): void
    {
        $client = static::createClient();
        $client->request('POST', '/api/v1/notify');

        self::assertResponseStatusCodeSame(401);
    }

    public function testAcceptsValidCredentials(): void
    {
        $client = static::createClient();
        $client->request('POST', '/api/v1/notify', server: [
            'PHP_AUTH_USER' => 'payments',
            'PHP_AUTH_PW' => 'test-secret',
        ], content: '{"status":"paid"}');

        self::assertResponseStatusCodeSame(204);
    }
}

401 or 403?
#

401 means the credentials are missing or wrong. The response carries WWW-Authenticate, so the client knows it has to send them. 403 means the login worked, but the user doesn't have the role that access_control asks for. So on a 401 look at the password, the env variable and its quotes. On a 403 look at roles. Status codes look like a detail, but interviewers notice them. 200 instead of 201 for a create endpoint is one of my own live coding lessons, more on that in notes from both sides of the interview table.

How do I log out of HTTP Basic?
#

You don't. The browser remembers the credentials for the realm and sends them again until it's closed, and the Symfony docs say the same: HTTP Basic has no logout. For a payment provider calling your API that doesn't matter. If people log in through a browser and need a logout button, HTTP Basic is the wrong tool. Use a login form.

When I wouldn't use it
#

For real users with their own passwords, same answer: a login form or a login link. And if the payment provider signs its notifications, check the signature as well. Basic auth only proves that the sender knows the password.