Restrict access
You can close any part of a website so that only a person who signs in can see it.
You don't build the sign-in: the web server authenticates the visitor before your code runs. The people who can enter are your domain users, with the same passwords and app passwords they already use for mail. There's nothing to turn on in the panel first.
Access control applies to the files under the document root. An
.htaccess file in the directory you want to protect turns it on, for
everything below that directory, including static files that never reach
PHP.
It does not cover a path that a mount gives to an app
server, a container, or a port. Such a request never touches the document
root, so the server never reads an .htaccess there.
It sits in front of your application, not inside it. A CMS such as
WordPress keeps its own login form and user accounts and knows nothing
about this. Restricting /wp-admin this way adds a second door in front
of the CMS door; it doesn't replace it, and it gives nobody a role inside
the CMS.
The two authentication methods
Access control is two decisions: how a visitor proves who they are
(AuthType), and who can then enter (Require).
| OpenID Connect (default) | HTTP basic authentication | |
|---|---|---|
| How to select it | Nothing to write. | AuthType Basic |
| What the visitor sees | A redirect to your sign-in portal, then a redirect back. | The browser's username and password box. |
| Credentials | A password, a passkey, and a second factor if the user's policy asks for one. The website never sees the password. | The full address as username, e.g. [email protected], and the mailbox password or an app password. |
| Second factor | Yes. | No. Basic authentication can't ask for one. |
| Session | Shared with the portal. Someone already signed in passes through without typing anything. | None. The browser sends the credentials on every request. |
| Best for | Anything a person opens in a browser. | A program (a script, a monitoring check, curl, a webhook sender, a mobile app), which can't follow the interactive redirect. |
The minimal rule for each method:
# OpenID Connect, the default
Require valid-user
# HTTP basic authentication
AuthType Basic
Require valid-user
Basic authentication sends the password on every request. An app password has a limited scope, is visible in the panel, and can be revoked on its own.
If a user's authentication policy limits them to app passwords, the mailbox password doesn't work here either. That's by design.
Choose who can enter
Require decides the audience, and which forms work depends on the
authentication method:
| Rule | Meaning | OpenID Connect | Basic |
|---|---|---|---|
Require valid-user | Any of your domain users | Yes | Yes |
Require user [email protected] | Named users | No | Yes |
Require group [email protected] | Members of a group | No | Yes |
Require claim email:[email protected] | Named users | Yes | No |
Require claim elpan:groups:[email protected] | Members of a group | Yes | No |
The two halves of the table are opposites: user and group work only
under basic authentication, the claim forms only under OpenID Connect.
A rule from the wrong half matches nobody.
With basic authentication, match the address directly. You can list
several addresses on one line, and combine Require lines with
<RequireAny> or <RequireAll>:
AuthType Basic
Require user [email protected] [email protected]
Under OpenID Connect, the same rule matches the email claim from the sign-in portal, not a username:
Require claim email:[email protected]
Require user refuses everyone under OpenID ConnectThe rule has no effect on that path, so the server refuses every request:
safe, but it looks like a broken rule. Add AuthType Basic, or rewrite
the rule as Require claim email:….
Let in a whole team
Naming people one at a time means editing the .htaccess whenever
somebody joins or leaves. Point the rule at a
group instead and the file never changes
again. The group is one of your group addresses, its members are domain
users on the group's domain, and adding somebody to the group in the panel
gives them access.
Use a group whenever the audience is a team rather than a fixed list.
The same group is usually also a working mail address: mail to
[email protected] reaches the people the rule lets in, so a team
that already has an address needs nothing new. If it has none, create one;
a group with no mail through it is still a good audience.
Each path uses a different form. Under basic authentication, name the group address directly:
AuthType Basic
Require group [email protected]
Under OpenID Connect, the default, the group arrives as a claim from the sign-in portal that lists the groups the visitor belongs to:
Require claim elpan:groups:[email protected]
Read that as one rule in three parts:
claim- the name of the claim,
elpan:groups - the group address to look for inside the claim
The colons are not a typo. The claim is named elpan:groups, so there are
two colons before the address.
You can list several groups on one line; a visitor in any of them can enter:
Require claim elpan:groups:[email protected] elpan:groups:[email protected]
To require membership of every named group, combine lines with
<RequireAll>. <RequireAny> and <RequireAll> work as they do elsewhere
in Apache.
A membership change needs a new sign-in
Adding somebody to a group, or removing them, does not change a session that's already open. The server reads the visitor's groups once, at sign-in, and they travel with the session. So:
- a colleague you add to the group is still refused while their browser holds an older session, and
- a person you remove keeps their access until that session ends.
A session ends after five minutes without activity, and after eight hours in any case. The next request then goes through the portal again and picks up the new membership, usually without a sign-in page, because the portal still recognizes the visitor.
In practice, a change reaches an idle visitor in about five minutes, while someone who keeps using the site keeps their old access as long as they continue.
Signing the user out from the panel ends their portal session, but the website has its own session and keeps accepting it. When a removal is urgent, and the person is leaving rather than changing teams, treat the group rule as one layer only and close the account itself as well.
A complete example
A staging site that nobody should see, except that an uptime check must still reach one path.
In the document root, every visitor signs in through the portal:
Require valid-user
The monitoring script can't follow a redirect, so in health/.htaccess
give it basic authentication and one named user:
AuthType Basic
Require user [email protected]
[email protected] is an ordinary domain user holding an app password
you issued to the monitoring system. You can revoke that password from the
panel without touching the site.
When the server refuses a request
A refused request lands in the website's error log, under Logs, with the user and the reason:
| Reason in the log | Means |
|---|---|
Password Mismatch | A wrong basic-authentication password. |
| An authorization failure | The credentials were correct, but no Require rule matched. |
That difference is the fastest way to tell a bad password from a rule that
matches nobody. A restricted page that returns 401 immediately, with no
redirect to the portal and no password box, usually means the visitor
authenticated but no Require rule matched them.
The log names the visitor by the internal ID from their sign-in, not by
their email address, so an authorization failure shows a long identifier
rather than [email protected]. To see who was refused, match the
identifier against the user in the panel.
A change to a user's password or app passwords takes effect in about one minute, because the web server caches its authentication decisions for a short time. Group membership under OpenID Connect is the exception: it follows the session, not that cache. See A membership change needs a new sign-in.