What this post covers
This is the thirteenth post in the "Digging into Apache httpd's Source" series. In Part 12 we saw how a handler turns a file into a response. This time we step back to a point before that — the place that decides "may this request come in at all?" before it ever reaches a handler.
You have probably seen a config like this in httpd.conf.
<Location /secret/>
AuthType Basic
AuthName "u13 lab"
AuthUserFile /tmp/htpasswd
Require valid-user
</Location>
The browser pops up a box asking for a user name and password, and if you type the right ones, you see the page. We follow where and how these five lines work inside the source. There are four questions to answer.
- How do "authentication" and "authorization" differ, and why did httpd split them into different modules?
- How do the module that reads
AuthUserFileand the module that decidesRequirefind each other? — a mechanism called providers - What data structure do
<RequireAll>,<RequireAny>andRequire notbecome, and by what rules are they decided? - Where in request processing do authentication and authorization sit, in what order — and why is the same decision made twice?
| Item | Details |
|---|---|
| Target | Apache httpd |
| Version | 2.5.1-dev (trunk) |
| Commit | e4af30fe6d (2026-10-24) |
| Files read most | include/mod_auth.h, server/provider.c, modules/aaa/mod_authz_core.c, modules/aaa/mod_auth_basic.c, modules/aaa/mod_authn_file.c, modules/aaa/mod_authz_user.c, modules/aaa/mod_authz_host.c, server/request.c |
Every line number and detail in this post refers to that commit. Part 1 explains how to get the source at the same commit and the Docker setup used to run the examples.
The modules/aaa/ folder, home of the access-control, authentication and authorization modules, holds twenty-four C files on its own (Digest, form login, LDAP, databases …). So that someone opening httpd's source for the first time can follow along, this post sticks to one common combination — Basic authentication + a password file + Require — and keeps only the main thread. What was left out is named under "Going deeper" at the end.
Words to know first
Authentication (shortened to authn in the source) answers "who are you?". Taking a user name and password and checking that they match is authentication. The result is a single user name, which httpd writes into the user field of the request_rec (r->user). The request_rec is the struct that holds everything about one request (Part 3).
Authorization (shortened to authz) decides "may this request come in?". It may be based on the user name (Require user alice), on the client address (Require ip 127.0.0.1), or on nothing about the user at all (Require all granted).
Basic authentication is the simplest authentication scheme defined by HTTP. When the server answers 401 Unauthorized with a WWW-Authenticate: Basic realm="…" header, the browser asks for a user name and password, Base64-encodes user:password, and sends the request again with it in an Authorization: Basic … header. Base64 is only a character conversion, not encryption, so in real use it should only travel over TLS (HTTPS) (Part 15).
A hook is a slot the core sets aside, saying "modules with work to do at this point, hang a function here". Hung functions are called in one of two ways: RUN_FIRST (stop as soon as one returns something other than DECLINED) and RUN_ALL (keep going as long as everyone returns OK or DECLINED; stop at the first error). Details are in Part 6.
The request processing phases are the hooks that ap_process_request_internal() calls in turn to map a URL to a file, merge the settings, check access, and pick a handler. We saw the whole order in Part 10; this post zooms into just the access-check part.
A provider is "a table of functions you look up by name". When a module starts, it registers a struct full of function pointers, saying "I am file in the authn group"; another module then finds that struct by the name file written in the config file. A function pointer is "a variable that holds the address of a function", and a struct of several of them works like an interface in C (Part 2). Section 2 covers this in detail.
1. Why authentication and authorization are split
Split the five config lines by the module that owns each one and you get this. Which module owns each directive was checked in each module's command_rec table (what a directive table is: Part 7).
| Config line | Owning module | What it does |
|---|---|---|
AuthType Basic, AuthName "…" |
mod_authn_core |
"authenticate here with the Basic scheme", the name shown in the login box |
| (the side that receives credentials) | mod_auth_basic |
decodes the Authorization header into user and password, then asks providers whether they match |
AuthUserFile /tmp/htpasswd |
mod_authn_file |
an authentication provider that finds that user's password in a file and compares |
Require valid-user |
mod_authz_core (+ mod_authz_user) |
collects Require lines into a tree and decides it. The valid-user check itself belongs to mod_authz_user, an authorization provider |
mod_authn_core.c lines 380–383, mod_auth_basic.c lines 202–203, mod_authn_file.c lines 49–51, mod_authz_core.c lines 688–690
Behind five lines there are five modules (mod_auth_basic never appears by name in the config, but it goes to work when it sees AuthType Basic). Why such fine pieces? Because three axes are independent of each other.
- How credentials arrive — Basic, Digest, a login form (
mod_auth_form), and the Bearer token new in trunk (mod_auth_bearer) - Where user data lives — a text file (
mod_authn_file), a DBM file, an SQL database (mod_authn_dbd), LDAP - The condition for letting someone in — user name, group, IP, environment variable, expression (expr)
With three ways of receiving and four stores, would you need twelve modules, one per pair? To avoid that, httpd puts one fixed function table (authn_provider) between "the receiving side" and "the checking side". The Basic module does not know whether the password lives in a file or a database, and the file module does not know whether the password came via Basic or a form. The config simply joins them by name, as in AuthBasicProvider file. Authorization works the same way. mod_authz_core finds an authorization provider by the first word after Require, hands the decision to it, and only combines the results with AND/OR itself.
Because only one function table sits between the receiving side and the store, there is no need for a separate module for every pairing.
Easy to confuse: the module name prefixes
The prefix of each file name in modules/aaa/ tells you its role. I put this rule together by looking at the folder's file list and at each file's register_hooks.
mod_auth_*(auth_basic,auth_digest,auth_form,auth_bearer) — the front end that receives credentials. They hang functions on hooks such ascheck_user_id.mod_authn_*(authn_file,authn_dbm,authn_dbd…) — authentication providers that check passwords. They register as providers, not on request processing hooks. The exception ismod_authn_core, the common module that ownsAuthTypeandAuthName.mod_authz_*(authz_user,authz_host,authz_groupfile…) — authorization providers for thexxxinRequire xxx. Onlymod_authz_coreis different: it is the central module that builds and decides the Require tree.mod_authnz_*(authnz_ldap,authnz_fcgi) — do both authentication and authorization.
2. Providers — function tables looked up by name
What the function tables look like
The function table an authentication provider must fill in is in include/mod_auth.h.
typedef struct {
/* Given a username and password, expected to return AUTH_GRANTED
* if we can validate this user/password combination.
*
* Use with AUTHN_PROVIDER_VERSION / AUTHN_PROVIDER_VERSION1 providers.
*/
authn_status (*check_password)(request_rec *r, const char *user,
const char *password);
/* Given a user and realm, expected to return AUTH_USER_FOUND if we
* can find a md5 hash of 'user:realm:password'
*
* Use with AUTHN_PROVIDER_VERSION / AUTHN_PROVIDER_VERSION1 providers.
*/
authn_status (*get_realm_hash)(request_rec *r, const char *user,
const char *realm, char **rethash);
} authn_provider;
include/mod_auth.h lines 95–112
authn_status (*check_password)(…) is a field holding "the address of a function that takes three arguments and returns an authn_status". There are two fields because each way of receiving credentials needs something different. Basic receives the password in plain text, so it asks check_password() "do this user and password match?". Digest authentication never receives the plain password, so it asks get_realm_hash() "give me this user's hash". A provider fills in only what it can do and leaves the rest NULL.
The return value is not just success or failure; there are several.
typedef enum {
AUTH_DENIED,
AUTH_GRANTED,
AUTH_USER_FOUND,
AUTH_USER_NOT_FOUND,
AUTH_GENERAL_ERROR,
AUTH_HANDLED
} authn_status;
include/mod_auth.h lines 69–76
What matters is that AUTH_DENIED (the user exists but the password is wrong) and AUTH_USER_NOT_FOUND (no such user) are separate. "Not found" means the next provider can be asked; "wrong" means it has to stop there. Section 3 shows where this difference is used.
The authorization provider's function table is in the same file.
typedef struct {
/* ... (doc comment trimmed) ... */
authz_status (*check_authorization)(request_rec *r,
const char *require_line,
const void *parsed_require_line);
/* ... (doc comment trimmed: "This function may be NULL.") ... */
const char *(*parse_require_line)(cmd_parms *cmd, const char *require_line,
const void **parsed_require_line);
} authz_provider;
include/mod_auth.h lines 141–161
parse_require_line() is called once, while the config is read, to interpret the words after Require ahead of time; check_authorization() is called for every request and decides using that interpretation (parsed_require_line). For example, so that the 127.0.0.1 in Require ip 127.0.0.1 is not re-parsed as a string on every request, mod_authz_host turns it into an array of subnet structs at config time (mod_authz_host.c lines 86–144). That result is passed around as a void * — "a pointer that can point at anything" — so each provider is free to give its parsed result whatever shape it likes.
There are five possible decisions.
typedef enum {
AUTHZ_DENIED,
AUTHZ_GRANTED,
AUTHZ_NEUTRAL,
AUTHZ_GENERAL_ERROR,
AUTHZ_DENIED_NO_USER /* denied because r->user == NULL */
} authz_status;
include/mod_auth.h lines 87–93
Keep in mind that besides granted and denied there are AUTHZ_NEUTRAL and AUTHZ_DENIED_NO_USER (denied because we don't know the user yet). These two are the keys to sections 4 and 5.
Registering and finding — server/provider.c
Providers are kept by the core in server/provider.c. The whole file is under 200 lines.
AP_DECLARE(apr_status_t) ap_register_provider(apr_pool_t *pool,
const char *provider_group,
const char *provider_name,
const char *provider_version,
const void *provider)
{
apr_hash_t *provider_group_hash, *provider_version_hash;
if (global_providers == NULL) {
global_providers = apr_hash_make(pool);
global_providers_names = apr_hash_make(pool);
apr_pool_cleanup_register(pool, NULL, cleanup_global_providers,
apr_pool_cleanup_null);
}
/* First, deal with storing the provider away */
provider_group_hash = apr_hash_get(global_providers, provider_group,
APR_HASH_KEY_STRING);
if (!provider_group_hash) {
provider_group_hash = apr_hash_make(pool);
apr_hash_set(global_providers, provider_group, APR_HASH_KEY_STRING,
provider_group_hash);
}
provider_version_hash = apr_hash_get(provider_group_hash, provider_name,
APR_HASH_KEY_STRING);
if (!provider_version_hash) {
provider_version_hash = apr_hash_make(pool);
apr_hash_set(provider_group_hash, provider_name, APR_HASH_KEY_STRING,
provider_version_hash);
}
/* just set it. no biggy if it was there before. */
apr_hash_set(provider_version_hash, provider_version, APR_HASH_KEY_STRING,
provider);
/* ... */
global_providers is a static global variable at the top of the file (lines 24–25). It is three layers of hashes (tables that look values up by name): group (e.g. "authn") → name (e.g. "file") → version (e.g. "0") → the address of the struct. The memory for the hashes comes from the pool passed in, and when that pool is destroyed cleanup_global_providers() runs and resets the globals to NULL. We saw pools and cleanups in Part 4. The version slot is there so that old and new modules do not get mixed up if the shape of a function table changes. In this commit the authentication and authorization versions are all "0" (mod_auth.h lines 39–46).
Lookup just walks down the same three layers.
AP_DECLARE(void *) ap_lookup_provider(const char *provider_group,
const char *provider_name,
const char *provider_version)
{
apr_hash_t *provider_group_hash, *provider_name_hash;
if (global_providers == NULL) {
return NULL;
}
provider_group_hash = apr_hash_get(global_providers, provider_group,
APR_HASH_KEY_STRING);
if (provider_group_hash == NULL) {
return NULL;
}
provider_name_hash = apr_hash_get(provider_group_hash, provider_name,
APR_HASH_KEY_STRING);
if (provider_name_hash == NULL) {
return NULL;
}
return apr_hash_get(provider_name_hash, provider_version,
APR_HASH_KEY_STRING);
}
server/provider.c lines 99–125
The return type is void *. provider.c has no idea whether the struct is an authn_provider or an authz_provider. The caller promises the type through the group name and casts it itself. That is why this mechanism is not just for authentication. Searching this commit for files that call ap_lookup_provider() also turns up caching (modules/cache/), logging (mod_log_config.c) and the proxy's load-balancing methods (mod_proxy_balancer.c), among others. The load-balancing side is in Part 14.
Here is where mod_authn_file registers itself.
static const authn_provider authn_file_provider =
{
&check_password,
&get_realm_hash,
};
static void opt_retr(void)
{
authn_cache_store = APR_RETRIEVE_OPTIONAL_FN(ap_authn_cache_store);
}
static void register_hooks(apr_pool_t *p)
{
ap_register_auth_provider(p, AUTHN_PROVIDER_GROUP, "file",
AUTHN_PROVIDER_VERSION,
&authn_file_provider, AP_AUTH_INTERNAL_PER_CONF);
ap_hook_optional_fn_retrieve(opt_retr, NULL, NULL, APR_HOOK_MIDDLE);
}
modules/aaa/mod_authn_file.c lines 166–182
It builds one static const struct holding the addresses of two functions, and registers that address under the name "file" in the "authn" group. register_hooks() is called once when the module is loaded (Part 6), so registration is already done by the time the config file starts being read. ap_register_auth_provider() wraps ap_register_provider(): if the last argument is AP_AUTH_INTERNAL_PER_CONF it bumps a counter, and it passes the other four arguments straight through (server/request.c lines 2214–2227). What that count is used for is noted under "Going deeper".
The yellow cylinder is provider.c's hash. Lookups happen once while the config is read; at request time the stored address is called directly.
Easy to confuse: the lookup already happened at config time
ap_lookup_provider() is not called per request. The function that handles AuthBasicProvider file looks the provider up on the spot and stores its address in the config struct (mod_auth_basic.c lines 93–107). Require works the same way (section 4). So if you forget LoadModule authz_host_module … and write Require ip …, startup fails while the config is read, not when a request arrives, with Unknown Authz provider: ip (mod_authz_core.c lines 415–419). For the same reason, the LoadModule line must come before any config that uses that module's providers.
3. Authentication — mod_auth_basic asks the providers
In its register_hooks(), mod_auth_basic hangs its authentication function with ap_hook_check_authn(authenticate_basic_user, …) (mod_auth_basic.c lines 493–500). Despite the different name, ap_hook_check_authn() ends up registering on the check_user_id hook (server/request.c lines 2265–2275), and check_user_id is a RUN_FIRST hook (server/request.c lines 85–86). Even if the Basic, Digest and form modules are all on this hook, each one first checks "is AuthType my scheme?" and steps aside with DECLINED if not, so in the end only one does the work (mod_auth_basic.c lines 302–306).
authenticate_basic_user() goes like this.
- If
AuthTypeis notBasic, returnDECLINED. If there is noAuthName, return 500 (lines 302–313). - Read the
Authorizationheader. If there is none, add aWWW-Authenticate: Basic realm="…"header and return 401. This response is what makes the browser show its login box (get_basic_auth()lines 248–286). - If there is one, decode the Base64, split at
:into user and password, and write the user intor->userfor now. The source comment itself says it sets the user "even though the user is unauthenticated at this point" (lines 282–283). - Ask the providers in the list, one after another. This is the code below.
- If the result is not
AUTH_GRANTED, return 401 (or 500). For 401, addWWW-Authenticateagain (lines 393–431).
current_provider = conf->providers;
do {
const authn_provider *provider;
/* For now, if a provider isn't set, we'll be nice and use the file
* provider.
*/
if (!current_provider) {
provider = ap_lookup_provider(AUTHN_PROVIDER_GROUP,
AUTHN_DEFAULT_PROVIDER,
AUTHN_PROVIDER_VERSION);
/* ... */
}
else {
provider = current_provider->provider;
apr_table_setn(r->notes, AUTHN_PROVIDER_NAME_NOTE, current_provider->provider_name);
}
if (digest) {
/* ... when AuthBasicUseDigestAlgorithm MD5: get_realm_hash() ... */
}
else {
auth_result = provider->check_password(r, sent_user, sent_pw);
}
apr_table_unset(r->notes, AUTHN_PROVIDER_NAME_NOTE);
/* Something occurred. Stop checking. */
if (auth_result != AUTH_USER_NOT_FOUND) {
break;
}
/* If we're not really configured for providers, stop now. */
if (!conf->providers) {
break;
}
current_provider = current_provider->next;
} while (current_provider);
modules/aaa/mod_auth_basic.c lines 331–391
Two spots are worth a look.
provider->check_password(…)— a call through a function pointer.mod_auth_basichas no idea whether there is a file or a database behind this address.if (auth_result != AUTH_USER_NOT_FOUND) break;— if you list several, as inAuthBasicProvider file dbm, it moves on to the next provider only when "no such user". If the password was wrong (AUTH_DENIED), it stops there. This is why the two values from section 2 are kept separate.
If you don't write AuthBasicProvider, conf->providers is empty, so it looks up the default AUTHN_DEFAULT_PROVIDER, which is "file" (mod_auth.h line 46). The example in this post also writes only AuthUserFile, without AuthBasicProvider.
mod_authn_file's check_password() opens the file on every request, reads it line by line looking for a line that starts with user:, and if found compares with ap_password_validate(). If no line is found it returns AUTH_USER_NOT_FOUND; if the comparison fails, AUTH_DENIED (mod_authn_file.c lines 57–110).
The five steps and the provider loop above, matched to the three requests of example 1 below. It moves to the next provider only on "no such user".
Example 1: htpasswd, and 401 → 200
The examples in this post were run in a Docker container (Ubuntu 24.04) with httpd 2.5.1-dev built as-is from the base commit (e4af30fe6d). The example folder is ex/u13-authn-authz/; a single run.sh creates the user file, writes the config, starts the server, sends requests with curl, and shows the error log. The core of the config is exactly the <Location /secret/> block at the top, plus two lines to make the log easier to read.
ErrorLogFormat "[%-m:%l] %M"
LogLevel warn authz_core:debug
ErrorLogFormat trims each log line down to [module:level] message (time, PID and client address are dropped), and LogLevel … authz_core:debug lets only mod_authz_core log down to debug level. How per-module log levels are implemented is in Part 16.
First we create the user file with htpasswd, which ships with httpd (-B means a bcrypt hash). The hash values change every run, so only the start is shown.
### 1. create the user file with htpasswd
Adding password for user alice
Adding password for user bob
alice:$2y$05$VlTvS8s...
bob:$2y$05$XmPtzxsqc...
Now three requests to /secret/. Shown are the status line, the WWW-Authenticate header, and the error log written during that request (log|).
### 2. /secret/ without credentials
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Basic realm="u13 lab"
log| [authz_core:debug] AH01626: authorization result of Require valid-user : denied (no authenticated user yet)
log| [authz_core:debug] AH01626: authorization result of <RequireAny>: denied (no authenticated user yet)
### 3. /secret/ alice:secret
HTTP/1.1 200 OK
log| [authz_core:debug] AH01626: authorization result of Require valid-user : denied (no authenticated user yet)
log| [authz_core:debug] AH01626: authorization result of <RequireAny>: denied (no authenticated user yet)
log| [authz_core:debug] AH01626: authorization result of Require valid-user : granted
log| [authz_core:debug] AH01626: authorization result of <RequireAny>: granted
### 4. /secret/ alice:wrong
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Basic realm="u13 lab"
log| [authz_core:debug] AH01626: authorization result of Require valid-user : denied (no authenticated user yet)
log| [authz_core:debug] AH01626: authorization result of <RequireAny>: denied (no authenticated user yet)
log| [auth_basic:error] AH01617: user alice: authentication failure for "/secret/index.html": Password Mismatch
401 → 200 is as expected. But the log has two odd things in it.
- A
<RequireAny>that we never wrote in the config shows up. - In request 3, where the password was right, the authorization decision is logged twice. The first says "denied, no authenticated user yet", the second "granted".
The first is the topic of section 4, the second of section 5. The Password Mismatch in request 4 is the case where check_password() in the code above returned AUTH_DENIED (mod_auth_basic.c lines 402–408). Request 2 has no auth_basic log line because the 401 for a missing header is not logged (lines 259–262).
4. Authorization — Require lines become a tree
One line, one node
mod_authz_core turns each Require line into one authz_section_conf struct.
struct authz_section_conf {
const char *provider_name;
const char *provider_args;
const void *provider_parsed_args;
const authz_provider *provider;
ap_method_mask_t limited;
authz_logic_op op;
int negate;
/** true if this is not a real container but produced by AuthMerging;
* only used for logging */
int is_merged;
authz_section_conf *first;
authz_section_conf *next;
};
modules/aaa/mod_authz_core.c lines 68–81
The same struct is used in two ways.
- Leaf (one
Require xxx …line):providerholds the provider's address andprovider_argsthe rest of the words. Withnotin front,negate = 1. - Branch (a group like
<RequireAll>):provideris empty,opholds AND/OR, andfirstthe first child. The children are chained one after another throughnext.
Holding any number of children with just two pointers, first and next, is a common way to represent a tree, known as "first child, next sibling". limited holds method restrictions such as <Limit GET> as bits.
The heart of the function that handles Require looks like this.
section->provider_name = ap_getword_conf(cmd->pool, &args);
if (!strcasecmp(section->provider_name, "not")) {
section->provider_name = ap_getword_conf(cmd->pool, &args);
section->negate = 1;
}
section->provider_args = args;
/* lookup and cache the actual provider now */
section->provider = ap_lookup_provider(AUTHZ_PROVIDER_GROUP,
section->provider_name,
AUTHZ_PROVIDER_VERSION);
/* ... "Unknown Authz provider" if missing; call parse_require_line() now if present ... */
if (!conf->section) {
conf->section = create_default_section(cmd->pool);
}
if (section->negate && conf->section->op == AUTHZ_LOGIC_OR) {
return apr_psprintf(cmd->pool, "negative %s directive has no effect "
"in %s directive",
cmd->cmd->name,
format_authz_command(cmd->pool, conf->section));
}
modules/aaa/mod_authz_core.c lines 398–453
If the first word is not, it marks the node as negated and uses the next word as the provider name. Then, if conf->section is empty, it creates a root with create_default_section(). That function is this.
static authz_section_conf* create_default_section(apr_pool_t *p)
{
authz_section_conf *section = apr_pcalloc(p, sizeof(*section));
section->op = AUTHZ_LOGIC_OR;
return section;
}
modules/aaa/mod_authz_core.c lines 382–389
op = AUTHZ_LOGIC_OR — this is the hidden <RequireAny> from the log. If you just write several Require lines inside a <Location> or <Directory>, those lines become children of an invisible <RequireAny>. That is why several Require lines directly under a block mean "pass if any one matches". The text <RequireAny> in the log message is the name given, when results are logged, to a node with no provider and op OR (format_authz_command() lines 368–380).
<RequireAll>, <RequireAny> and <RequireNone> are handled by add_authz_section(). It creates a new branch node whose op and negate depend on the block name, processes the lines inside the block with ap_walk_config() so they hang under that node, and then attaches the node as a child of the outer node (mod_authz_core.c lines 473–573). One exception: if the block is not negated and its op matches the outer node's (say, a <RequireAny> under the hidden root), the new node is dropped and its children are linked straight into the outer node (lines 547–550). That is why the /any/ log in the example shows <RequireAny> only once. Block names map to nodes like this (lines 499–512).
| Config | op |
negate |
Meaning |
|---|---|---|---|
<RequireAll> |
AND | 0 | all must pass |
<RequireAny> |
OR | 0 | one passing is enough |
<RequireNone> |
OR | 1 | none may pass |
(<RequireNotAll>) |
AND | 1 | in the source, but unusable because AUTHZ_EXTRA_CONFIGS is off |
The <RequireNotAll> directive sits inside #ifdef AUTHZ_EXTRA_CONFIGS, and the top of the file switches it off with #undef AUTHZ_EXTRA_CONFIGS (line 48, lines 699–704). #ifdef is a C preprocessor directive that includes or drops code before compilation.
The example's /mixed/ config becomes this tree.
<Location /mixed/>
AuthType Basic
AuthName "u13 lab"
AuthUserFile /tmp/htpasswd
<RequireAll>
Require valid-user
Require not user bob
</RequireAll>
</Location>
Yellow boxes are branches (groups); blue and red boxes are leaves (one Require line each). The red one is a leaf with not.
Deciding — apply_authz_sections()
When a request comes in, this tree is decided by recursing down from the root.
static authz_status apply_authz_sections(request_rec *r,
authz_section_conf *section,
authz_logic_op parent_op)
{
authz_status auth_result;
/* ... skip a node that does not apply to this method (<Limit>) ... */
if (section->provider) {
apr_table_setn(r->notes, AUTHZ_PROVIDER_NAME_NOTE,
section->provider_name);
auth_result =
section->provider->check_authorization(r, section->provider_args,
section->provider_parsed_args);
apr_table_unset(r->notes, AUTHZ_PROVIDER_NAME_NOTE);
}
else {
authz_section_conf *child = section->first;
auth_result = AUTHZ_NEUTRAL;
while (child) {
authz_status child_result;
child_result = apply_authz_sections(r, child, section->op);
if (child_result == AUTHZ_GENERAL_ERROR) {
return AUTHZ_GENERAL_ERROR;
}
if (child_result != AUTHZ_NEUTRAL) {
/* ... */
if (section->op == AUTHZ_LOGIC_AND) {
if (child_result == AUTHZ_DENIED) {
auth_result = child_result;
break;
}
if ((child_result == AUTHZ_DENIED_NO_USER
&& auth_result != AUTHZ_DENIED)
| (auth_result == AUTHZ_NEUTRAL)) {
auth_result = child_result;
}
}
else {
/* AUTHZ_LOGIC_OR */
if (child_result == AUTHZ_GRANTED) {
auth_result = child_result;
break;
}
if ((child_result == AUTHZ_DENIED_NO_USER
&& auth_result == AUTHZ_DENIED)
| (auth_result == AUTHZ_NEUTRAL)) {
auth_result = child_result;
}
}
}
child = child->next;
}
}
if (section->negate) {
if (auth_result == AUTHZ_GRANTED) {
auth_result = AUTHZ_DENIED;
}
else if (auth_result == AUTHZ_DENIED ||
auth_result == AUTHZ_DENIED_NO_USER) {
/* For negated directives, if the original result was denied
* then the new result is neutral since we can not grant
* access simply because authorization was not rejected.
*/
auth_result = AUTHZ_NEUTRAL;
}
}
/* ... log AH01626 "authorization result of …" ... */
return auth_result;
}
modules/aaa/mod_authz_core.c lines 722–821
The AH01626 log at the very end, in the trimmed part, is the authorization result of … line that the example prints for every node (lines 815–818). It is logged after all children are decided, so in the log the leaves come first and the root last.
It is long, but the rules fit in one table.
| Situation | Result |
|---|---|
| Leaf | whatever the provider's check_authorization() returned |
| AND branch | any child DENIED → DENIED immediately. Otherwise, any DENIED_NO_USER → that; if none, GRANTED. All NEUTRAL → NEUTRAL |
| OR branch | any child GRANTED → GRANTED immediately. Otherwise DENIED_NO_USER wins over DENIED |
Child is NEUTRAL |
not counted (as if it were absent) |
Node with negate |
GRANTED → DENIED; DENIED/DENIED_NO_USER → NEUTRAL |
The last row is the most important decision in this function. Require not user bob means "deny if bob", not "grant if not bob". In the words of the comment, "we can not grant access simply because authorization was not rejected", so a negated result is at best neutral. That means negation alone can never let anyone in; it must always be grouped with a positive line inside <RequireAll>. There is even code that checks this rule at config time (Example 3).
DENIED_NO_USER, meaning "this might change if we knew the user", is kept alive all the way up. It rises in AND as long as no other child is a definite DENIED, and in OR as long as there is no GRANTED. Only providers that use user information return this value. valid-user is this short.
static authz_status validuser_check_authorization(request_rec *r,
const char *require_line,
const void *parsed_require_line)
{
if (!r->user) {
return AUTHZ_DENIED_NO_USER;
}
return AUTHZ_GRANTED;
}
modules/aaa/mod_authz_user.c lines 85–94
Require user also returns AUTHZ_DENIED_NO_USER when there is no r->user, and otherwise compares against the listed names one by one (lines 58–82). Require ip, on the other hand, has nothing to do with the user, so it only returns GRANTED or DENIED (mod_authz_host.c lines 146–161).
The authorization providers
These are the authorization providers that the three modules seen in this post register in this commit (checked in each register_hooks()).
Word after Require |
Registered by | Decision |
|---|---|---|
all granted / all denied |
mod_authz_core |
at config time leaves a mark if granted; at request time grants or denies by that mark (lines 982–1002) |
env var… |
mod_authz_core |
grants if any of the environment variables exists |
method GET … |
mod_authz_core |
grants if the request method is in the list |
expr expression |
mod_authz_core |
grants if the expression is true. If the expression mentions REMOTE_USER, false gives DENIED_NO_USER (lines 1106–1129) |
user name…, valid-user |
mod_authz_user |
as seen above |
ip, host, forward-dns, local |
mod_authz_host |
client address and host name |
mod_authz_core.c lines 1149–1160, mod_authz_user.c lines 128–134, mod_authz_host.c lines 386–398
Require group is registered the same way by mod_authz_groupfile, and things like Require ldap-user by mod_authnz_ldap. If you need a new condition, you write a module that fills in one authz_provider and registers it; mod_authz_core needs no changes.
Easy to confuse: an inner block's Require "overrides" the outer one
The example config has Require all granted in <Directory /tmp/www>, and Require ip 127.0.0.1 in <Location /local/> inside it. Are the two ANDed together? Look at the log in Example 2: for the /local/ request, only Require ip is decided. The outer block's Require all granted is gone.
That is how the config merge function is written. If the inner block has at least one Require and no AuthMerging was written (AUTHZ_LOGIC_UNSET), the inner config is used as is. Only when the inner block has no Require at all does it inherit the outer one (merge_authz_core_dir_config() lines 115–169). With AuthMerging And or AuthMerging Or, it builds a new branch with the outer tree and the inner tree as its two children (lines 131–160). That branch gets an is_merged mark so the log shows it as AuthMerging All/AuthMerging Any. Directory config merging in general was covered in Part 7.
5. Order in request processing — the same decision, twice
mod_authz_core hangs the same decision on two hooks
static void register_hooks(apr_pool_t *p)
{
APR_REGISTER_OPTIONAL_FN(authz_some_auth_required);
ap_hook_pre_config(authz_core_pre_config, NULL, NULL, APR_HOOK_MIDDLE);
ap_hook_check_config(authz_core_check_config, NULL, NULL, APR_HOOK_MIDDLE);
ap_hook_check_authz(authorize_user, NULL, NULL, APR_HOOK_LAST,
AP_AUTH_INTERNAL_PER_CONF);
ap_hook_check_access_ex(authorize_userless, NULL, NULL, APR_HOOK_LAST,
AP_AUTH_INTERNAL_PER_CONF);
/* ... provider registrations ... */
}
modules/aaa/mod_authz_core.c lines 1138–1161
authorize_userless() goes on the access_checker_ex hook and authorize_user() on the auth_checker hook. But each of them is a one-line body that calls the same function, only with a different argument: authorize_user_core(r, 0) and authorize_user_core(r, 1) (lines 914–922). The second argument is named after_authn — "has authentication finished?". The same tree is decided once before authentication and once after. The heart of authorize_user_core(), which turns the result into an HTTP response, is this.
auth_result = apply_authz_sections(r, conf->section, AUTHZ_LOGIC_AND);
if (auth_result == AUTHZ_GRANTED) {
return OK;
}
else if (auth_result == AUTHZ_DENIED_NO_USER) {
if (after_authn) {
/* ... log AH01629 ... */
return HTTP_UNAUTHORIZED;
}
else {
/*
* We need a user before we can decide what to do.
* Get out of the way and proceed with authentication.
*/
return DECLINED;
}
}
else if (auth_result == AUTHZ_DENIED || auth_result == AUTHZ_NEUTRAL) {
if (!after_authn || ap_auth_type(r) == NULL) {
/* ... log AH01630 "client denied by server configuration" ... */
return HTTP_FORBIDDEN;
}
else {
/* ... log AH01631, then 403 or 401 depending on AuthzSendForbiddenOnFailure ... */
}
}
modules/aaa/mod_authz_core.c lines 849–905
The first pass (before authentication) ends in one of three ways (a provider error, AUTHZ_GENERAL_ERROR, ends in 500 — lines 906–911).
GRANTED→OK. Granted without knowing the user (e.g. an address matchingRequire ip 127.0.0.1). Authentication is skipped entirely.DENIED/NEUTRAL→ 403. A refusal that would not change even if we knew the user. No need to ask for a password.DENIED_NO_USER→DECLINED. In the comment's words, "we need a user before we can decide what to do. Get out of the way and proceed with authentication."
This is where the reason for a separate DENIED_NO_USER value shows. Without it, the first pass could not tell "a refusal that needs authentication" from "a refusal that authentication would not fix".
request.c picks the next step from this result
The caller is ap_process_request_internal(), which we saw in Part 10. After mapping the URL to a file and merging the directory config, and right before type_checker and fixups, comes this part. How each hook is run can be checked in the implementation macros near the top of server/request.c: access_checker is RUN_ALL, and access_checker_ex, check_user_id and auth_checker are RUN_FIRST (lines 85–96).
switch (ap_satisfies(r)) {
case SATISFY_ALL:
case SATISFY_NOSPEC:
if ((access_status = ap_run_token_checker(r)) != OK &&
access_status != DECLINED) {
return decl_die(access_status,
"check token (with Satisfy All)", r);
}
if ((access_status = ap_run_access_checker(r)) != OK) {
return decl_die(access_status,
"check access (with Satisfy All)", r);
}
access_status = ap_run_access_checker_ex(r);
if (access_status == DECLINED
| (access_status == OK && ap_run_force_authn(r) == OK)) {
if ((access_status = ap_run_check_user_id(r)) != OK) {
return decl_die(access_status, "check user", r);
}
if (r->user == NULL) {
/* don't let buggy authn module crash us in authz */
/* ... log AH00027, 500 ... */
}
if ((access_status = ap_run_auth_checker(r)) != OK) {
return decl_die(access_status, "check authorization", r);
}
}
else if (access_status == OK) {
ap_log_rerror(APLOG_MARK, APLOG_TRACE3, 0, r,
"request authorized without authentication by "
"access_checker_ex hook: %s", r->uri);
}
else {
return decl_die(access_status, "check access", r);
}
break;
case SATISFY_ANY:
/* ... */
server/request.c lines 336–379
Reading it in order:
token_checker— the slot for token authentication new in trunk (mod_auth_bearer). It stops only on a value other thanOKorDECLINED. The 2.4.x branch'srequest.cdoes not have this call.access_checker(RUN_ALL) — access checks that have nothing to do with the user. In this commitmod_access_compat(the module with the olderAllow from/Deny from/Orderdirectives) andmod_ssl, among others, hang functions here. If any one is notOK, it ends.access_checker_ex(RUN_FIRST) —mod_authz_core's first pass.- If 3 was
DECLINED(orOKbut theforce_authnhook saysOK— no module undermodules/in this commit uses that hook), authenticate withcheck_user_id. If the Basic module returns 401, it ends here. If authentication saysOKbutr->useris empty, that is treated as a bug in the authentication module and gives 500. - Then
auth_checker(RUN_FIRST) —mod_authz_core's second pass. Nowr->useris set. - If 3 was
OK, the request passes without authentication. The trace3 log "request authorized without authentication…" is written at this point. - If 3 was anything else (403 etc.), that is the end.
The path under Satisfy All (the default). Blue boxes are hooks; red boxes are where the request ends. The token check (token_checker) is left out of the diagram.
decl_die() treats a hook returning DECLINED (no module took it) as a configuration error and turns it into 500; any other value is logged at trace3 and returned as is (lines 115–128). What happens if step 4 is reached in a place with no AuthType? mod_authn_core hangs authenticate_no_user() at the very end of the check_user_id hook (APR_HOOK_LAST), which returns OK when there is no AuthType (mod_authn_core.c lines 390–415). Then r->user is empty, so the 500 in step 4 follows. That is the path by which a config with Require valid-user but no AuthType produces a 500 (I did not run this as an example).
Where Satisfy comes from
The value in switch (ap_satisfies(r)) comes from the Satisfy All/Satisfy Any directive. But that directive belongs to mod_access_compat, not to the core. The core's ap_satisfies() calls that module's optional function (a function that is called if the module is present and skipped otherwise) if it has been provided, and otherwise returns SATISFY_NOSPEC (server/core.c lines 842–850). So a config that does not load mod_access_compat (like this post's example) always takes the same path as SATISFY_ALL.
With Satisfy Any, if step 2's access_checker is OK, both authentication and authorization are skipped (lines 388–394). That is how older configs said "come from this IP, or else give a password". With Require, putting Require ip and Require valid-user together in a <RequireAny> does the same thing: if the IP matches, the first pass is GRANTED and authentication is skipped; if not, it is DENIED_NO_USER and authentication is required. Example 2 is exactly that.
ap_some_authn_required() lives in the same file too (lines 130–164). It is a public function that runs steps 2 and 3 above ahead of time and reports whether "access_checker_ex returns DECLINED" — that is, whether this request needs authentication. Afterwards it puts r->user back to its original value. I found no caller of this function in this commit's modules/ or server/; it looks like an API offered for outside modules.
Example 2: requests that end on the first pass, and requests that reach the second
These are the other two blocks of the example config.
<Location /local/>
Require ip 127.0.0.1
</Location>
<Location /any/>
AuthType Basic
AuthName "u13 lab"
AuthUserFile /tmp/htpasswd
<RequireAny>
Require ip 127.0.0.1
Require valid-user
</RequireAny>
</Location>
Sending from inside the container to 127.0.0.1 makes the client address 127.0.0.1; sending to the container's own IP (172.17.x.x on the Docker network — it may differ between runs) makes the client address that IP. That is how "another address" was produced.
### 5. /local/ from 127.0.0.1
HTTP/1.1 200 OK
log| [authz_core:debug] AH01626: authorization result of Require ip 127.0.0.1: granted
log| [authz_core:debug] AH01626: authorization result of <RequireAny>: granted
### 6. /local/ from the container IP (another address)
HTTP/1.1 403 Forbidden
log| [authz_core:debug] AH01626: authorization result of Require ip 127.0.0.1: denied
log| [authz_core:debug] AH01626: authorization result of <RequireAny>: denied
log| [authz_core:error] AH01630: client denied by server configuration: /tmp/www/local/index.html
### 7. /any/ from 127.0.0.1, no credentials
HTTP/1.1 200 OK
log| [authz_core:debug] AH01626: authorization result of Require ip 127.0.0.1: granted
log| [authz_core:debug] AH01626: authorization result of <RequireAny>: granted
### 8. /any/ from another address, no credentials
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Basic realm="u13 lab"
log| [authz_core:debug] AH01626: authorization result of Require ip 127.0.0.1: denied
log| [authz_core:debug] AH01626: authorization result of Require valid-user : denied (no authenticated user yet)
log| [authz_core:debug] AH01626: authorization result of <RequireAny>: denied (no authenticated user yet)
### 9. /any/ from another address, alice
HTTP/1.1 200 OK
log| [authz_core:debug] AH01626: authorization result of Require ip 127.0.0.1: denied
log| [authz_core:debug] AH01626: authorization result of Require valid-user : denied (no authenticated user yet)
log| [authz_core:debug] AH01626: authorization result of <RequireAny>: denied (no authenticated user yet)
log| [authz_core:debug] AH01626: authorization result of Require ip 127.0.0.1: denied
log| [authz_core:debug] AH01626: authorization result of Require valid-user : granted
log| [authz_core:debug] AH01626: authorization result of <RequireAny>: granted
Let's pin down where in the order above each request ended.
- 5 and 7 — the first pass ends with
granted, and there is only one decision. Request 7 never asked for a password even thoughAuthType Basicis set, because it never reached the authentication step. - 6 — the first pass says
denied.after_authnis 0, so 403 andAH01630 client denied by server configuration. That familiar log line comes frommod_authz_core.clines 878–884. - 8 — first pass
DENIED_NO_USER→DECLINED→ incheck_user_id,mod_auth_basicfinds no header and returns 401. The second pass never ran, so only first-pass lines are logged. - 9 — first pass
DENIED_NO_USER→ authentication succeeds (r->user = alice) → in the second passvalid-userisgranted. Two sets of decision lines.
Request 9 of the example. The grey boxes match the decisions printed in the log.
Raise the global log level to trace3 and you can also see the lines request.c writes. Below, only LogLevel in the config above was changed to LogLevel trace3; /any/ was requested from 127.0.0.1 and /secret/ without credentials, and the relevant lines were picked out. The number lines are status codes, and the Setting LogLevel for all modules to trace3 lines that go to standard error when httpd starts and stops were removed.
### 12. request.c decisions at trace3 (/any/, /secret/)
200
log| [core:trace3] request authorized without authentication by access_checker_ex hook: /any/index.html
401
log| [core:trace3] auth phase 'check user' gave status 401: /secret/index.html
The first line is step 6 in the list above (access_checker_ex said OK); the second is written when decl_die() passes check_user_id's 401 straight through. request.c is part of the core rather than a module, so it shows up as [core:…].
Easy to confuse: an authenticated user who is refused gets 401, not 403
Log in to the example's /mixed/ as alice and you get 200 (request 10; in the second pass Require not user bob: neutral, <RequireAll>: granted). Now as bob.
### 11. /mixed/ bob
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Basic realm="u13 lab"
log| [authz_core:debug] AH01626: authorization result of Require valid-user : denied (no authenticated user yet)
log| [authz_core:debug] AH01626: authorization result of Require not user bob: neutral
log| [authz_core:debug] AH01626: authorization result of <RequireAll>: denied (no authenticated user yet)
log| [authz_core:debug] AH01626: authorization result of <RequireAny>: denied (no authenticated user yet)
log| [authz_core:debug] AH01626: authorization result of Require valid-user : granted
log| [authz_core:debug] AH01626: authorization result of Require not user bob: denied
log| [authz_core:debug] AH01626: authorization result of <RequireAll>: denied
log| [authz_core:debug] AH01626: authorization result of <RequireAny>: denied
log| [authz_core:error] AH01631: user bob: authorization failure for "/mixed/index.html":
The rules from section 4 show up exactly.
- In the first pass,
Require not user bobisneutral. With no user, theuserprovider returnsDENIED_NO_USER, and negation turns that intoNEUTRAL. - alice in the second pass:
user bobisDENIED→ negated toNEUTRAL. AND does not countNEUTRAL, sovalid-user'sgrantedremains. - bob in the second pass:
user bobisGRANTED→ negated toDENIED. With oneDENIEDin the AND,<RequireAll>isdeniedtoo.
But bob typed the right password, and still the response is 401, not 403, so the browser shows the login box again. That is because the last branch of authorize_user_core() returns 401 unless AuthzSendForbiddenOnFailure is turned on (authz_forbidden_on_fail is UNSET, i.e. -1) (lines 886–903). The directive's description also talks about sending "'403 FORBIDDEN' … instead of the HTTP-conforming '401 UNAUTHORIZED'" (lines 713–718). In other words, the default gives the user a chance to log in again with a different account.
As a bonus, Require valid-user : in the log has a space before the colon. format_authz_command(), which builds the log text, appends one space and the arguments after the name, and valid-user has an empty argument string (lines 372–374). Good to know when you grep the log.
Example 3: negation is checked at config time
Section 4 said "negation alone can never let anyone in". mod_authz_core blocks such configs at startup. These were run in the same container with httpd -t (check the config and exit).
### 13. config check: a single Require not in <Location>
AH00526: Syntax error on line 10 of /tmp/bad1.conf:
negative Require directive has no effect in <RequireAny> directive
### 14. config check: <RequireAll> with only negations
AH01624: <RequireAll> directive contains only negative authorization directives
AH00014: Configuration check failed
### 15. correct: one positive + one negative in <RequireAll>
Syntax OK
- 13 — only one line,
Require not ip 10.0.0.1, inside<Location /x/>. The error message talks about<RequireAny>— that hidden root we never wrote. A negated line directly under an OR node has no effect, since "pass if any other child grants". This is checked the moment theRequireis read (lines 448–453). It is an easy thing to write when you want to "block just one IP and allow everyone else"; you need to wrap it in<RequireAll>and addRequire all granted(15). - 14 — with only negations inside
<RequireAll>, after the whole config is read,authz_core_check_section()in thecheck_confighook stops startup saying it "contains only negative" directives (lines 597–654). This function also flattens one level using De Morgan's law when a negated branch'sopdiffers from its parent's (lines 610–628).
Going deeper
Things this post only named or did not cover.
AP_AUTH_INTERNAL_PER_CONF— the last argument passed when registering hooks and providers. Only when all access-check hooks and providers are registered with this value do internal requests (subrequests and internal redirects) reuse the cache of the directory/location walks (cached &= auth_internal_per_conf, e.g. line 875), and if the config obtained that way is the same as the previous request's, the authn/authz result is inherited instead of running them again. Counted atserver/request.clines 2176–2212, inherited at lines 314–326, constants described atinclude/http_request.hlines 200–228.<AuthzProviderAlias>,<AuthnProviderAlias>— bundle settings with a provider and register them under an alias.mod_authz_core.clines 188–355,mod_authn_core.clines 201–292.- Digest authentication and
get_realm_hash()—modules/aaa/mod_auth_digest.c.AuthBasicUseDigestAlgorithm MD5in Basic uses the same function (mod_auth_basic.clines 322–373). - Password comparison —
ap_password_validate(), fromserver/util.cline 3566. - Caching authentication results —
mod_authn_socache.c, and the optional functionap_authn_cache_storethat providers call (mod_auth.hlines 163–173). - Token authentication in trunk — the
authtgroup (autht_providerinmod_auth.h),mod_autht_core.c,mod_autht_jwt.c,mod_auth_bearer.c, and thetoken_checkerhook. The 2.4.x branch has neither these files nor thetoken_checkercall. - The hook that adds headers to a 401 —
note_auth_failure,ap_note_auth_failure().mod_auth_basic'shook_note_basic_auth_failure()(lines 239–246). AuthBasicFake— builds a fakeAuthorizationheader for a back-end server (a proxy target);authenticate_basic_fake()on thefixupshook (lines 439–491).<Limit>and thelimitedbits — per-method Require. Lines 728–740 ofapply_authz_sections().
Recap
| Concept | One-line summary |
|---|---|
| Authentication (authn) | "Who are you?". The result is r->user. Done by a front end such as mod_auth_basic on the check_user_id hook |
| Authorization (authz) | "May you come in?". mod_authz_core decides the Require tree |
| Provider | A function table registered and looked up by group, name and version. Looked up at config time, address kept (server/provider.c) |
authn_provider |
check_password(), get_realm_hash(). "No such user" moves to the next provider; "wrong password" stops |
authz_provider |
parse_require_line() (once, at config time), check_authorization() (every request) |
| Require tree | Each line is a leaf, <RequireAll/Any/None> a branch. Lines directly under a block are children of a hidden <RequireAny> |
NEUTRAL |
A result that is not counted. A negated (not) refusal becomes neutral, not a grant |
DENIED_NO_USER |
"A refusal that might change if we knew the user". The signal from the first pass that calls for authentication |
| Two passes | Once without a user in access_checker_ex, once after authentication in auth_checker. If the first pass decides, authentication is skipped |
AuthMerging |
By default the inner block's Require replaces the outer one. And/Or combine them |
Satisfy |
An older directive of mod_access_compat. Without it, SATISFY_NOSPEC takes the same path as All |
What to look at next
With this post we have gone all the way around the main path: a request comes in and is judged, a handler builds the response (Part 12), and it flows out through filters (Part 11). From here on we look at the big modules that sit on top of that main path.
The first is mod_proxy, which you meet most often when using httpd as a front-end server. We see how one ProxyPass line becomes a connection to a back-end server, how connections are reused, and how load-balancing methods (byrequests and others) are swapped in as the providers from this post. The story continues in Part 14: mod_proxy — Reverse Proxy and Load Balancer.
Compared with Spring: the Spring Framework itself has no authentication or authorization features; a separate project, Spring Security, does this on top of the servlet filter chain (neither this series nor the Spring series read Spring Security's source). Inside the Spring Framework, the closest thing to "the place that decides whether a request may pass before it reaches the handler" is HandlerInterceptor.preHandle() from Spring Part 23. The difference is that there, interceptors return either true or false and are called one after another in a line, while httpd hands the decision to providers and combines GRANTED, DENIED, NEUTRAL and DENIED_NO_USER in a tree. The hooks that let modules step into the core's flow are in Part 6, and Spring's way of solving that with PostProcessors is in Spring Part 6.