Which header response code to send on a failed login attempt is not a simple decision to make. Traditionally, a variety of response codes have been used for different reasons.
The header response code is, usually, invisible to the user, and in many cases of no significance. We could therefor even use a "200" response, although it would not be suitable, and it might violate the standard. No one is forcing us to comply however, and there could be circumstances where you would want to act differently—this will not apply in most circumstances.
In most circumstances we will want to use a specific header response code in order that search engines and other "bots" will also understand the response. However, with error pages, this has not always been the case.
For example, it has been suggested that we deliberately falsify and randomize login failure pages to break automated brute force attempts and confuse bots. This is, however, very ineffective, since a bot could just look for known strings in the response body to identify whether a login was successful. For these things, rate-limiting is probably going to be much more effective, and therefor there I personally see no reason why not to use the correct response code.
So which response code should we use?
The 403 response code is appropriate for HTML form-based logins, while the 401 code should be used for HTTP based logins.
In PHP, you can deliver a status codes using the build-in http_response_code function, and thereby avoid having to deal with protocol identification otherwise needed when sending bare headers:
http_response_code(403); echo $body_response;
Why use 403 and not 401?
The 401 status code is used in context of HTTP authentication. This is a type of login that uses build-in authentication in the HTTP protocol, using HTTP headers, rather than HTML forms and POST requests.
The 401 (Unauthorized) status code indicates that the request has not been applied because it lacks valid authentication credentials for the target resource. The server generating a 401 response MUST send a WWW-Authenticate header field (Section 4.1) containing at least one challenge applicable to the target resource.
The 401 Unauthorized status code also requires the WWW-Authenticate header to be delivered along with the 401 code, and this header is not used for HTML-based logins. Because of that, we should instead use another status code.
HTTP is a stateless protocol, so, when HTTP authentication is used, the credentials is provided by the client in the authorization request header field on every single request. Each request is ignorant about any previous requests. However, when a HTML form is used, the credentials is typically only provided once (on log in), after which a session cookie is created and used to maintain a "logged-in" state.
This "old'ish" looking popup, asking to enter a username and password, is how HTTP authentication looks in a browser:
HTML form-based authentication is more flexible, and allows for custom styling. A login form is ideally submitted using a HTTP POST request. If authentication fails, we may then use the 403 status code.
According to the RFC, a 403 Forbidden status may be used when the server understood a request, but refuses to authorize it. This makes it ideal for many different purposes, including failed logins. In addition, the RFC also has this to say:
If authentication credentials were provided in the request, the server considers them insufficient to grant access. The client SHOULD NOT automatically repeat the request with the same credentials. The client MAY repeat the request with new or different credentials. However, a request might be forbidden for reasons unrelated to the credentials.
So, if the credentials are incorrect, we may safely use a 403 status code, to comply with the RFC proposed standard.