Hardening
View SourceIntroduction
The Erlang/OTP inets application provides an HTTP client (httpc) and an
HTTP server (httpd), intended to be used as libraries in other applications.
Different deployments have very different security requirements. A development
tool running on localhost has different needs than a service exposed to the
public internet. Because of this, inets ships with permissive defaults that
prioritize ease of use. This guide describes how to tighten those defaults for
production deployments.
This guide targets OTP 29. Where behavior differs from OTP 28 and earlier, the differences are noted inline. A summary of changes is provided in the Changes from OTP 28 section.
For general configuration, see the httpc and httpd reference manuals.
HTTP Client (httpc)
TLS Configuration
Since OTP 26.0, httpc verifies the server certificate by default when no
{ssl, ...} option is given. The default is equivalent to calling
httpc:ssl_verify_host_options(true), which enables verify_peer with the
system CA store.
If you need to customize TLS settings (for example, to pin a specific CA or restrict protocol versions), pass them explicitly:
SslOpts = [{verify, verify_peer},
{cacertfile, "/path/to/ca-bundle.crt"},
{versions, ['tlsv1.2', 'tlsv1.3']}],
httpc:request(get, {"https://example.com", []}, [{ssl, SslOpts}], []).Warning
Setting {ssl, [{verify, verify_none}]} disables certificate verification
entirely. This makes the connection vulnerable to man-in-the-middle attacks
and should only be used for testing.
Timeouts
The default value for both timeout and connect_timeout is infinity,
meaning a request can hang indefinitely. Always set explicit timeouts in
production:
HttpOpts = [{timeout, 30000}, %% 30 s total request timeout
{connect_timeout, 5000}], %% 5 s TCP connect timeout
httpc:request(get, {"https://example.com", []}, HttpOpts, []).timeout- Maximum time for the entire request (connect + send + receive). Default:infinity.connect_timeout- Maximum time for the TCP connection setup. Defaults to the value oftimeout.autoretry(since OTP 28.4) - Controls how long the client honors a server'sRetry-Afterheader before retrying. The client automatically retries the request once on a 503 response with aRetry-Afterheader. Default:infinity(always honor the server's value). Set to0to disable automatic retries, or to a finite number of milliseconds to cap the wait. If the server'sRetry-Aftervalue exceeds the configured limit, no retry is performed. Before OTP 28.4, the retry behavior was hardcoded to only honorRetry-Aftervalues of 99 seconds or less.HttpOpts = [{timeout, 30000}, {connect_timeout, 5000}, {autoretry, 10000}]. %% Wait at most 10 s for Retry-After
Redirect Following
By default, httpc follows HTTP redirects automatically
({autoredirect, true}). This can be exploited for Server-Side Request
Forgery (SSRF) if the client is used to fetch user-supplied URLs, because a
redirect can point to internal hosts or services.
For requests to untrusted URLs, disable automatic redirects and validate the target manually. Check that the redirect target does not resolve to a private network address (10.x, 127.x, 169.254.x, etc.) before following it:
httpc:request(get, {UserUrl, []},
[{autoredirect, false}, {timeout, 10000}], []).Client Connection Limits
Profile-level options control how many connections httpc keeps open. The
per-host defaults are conservative, but the global limit defaults to
infinity:
max_connections_open(since OTP 29) - Maximum total number of open handlers across all hosts. Default:infinity. Set a finite value to prevent unbounded resource consumption:httpc:set_options([{max_connections_open, 100}]).max_sessions- Maximum persistent connections per host:port. Default:2.max_keep_alive_length- Maximum queued requests on a keep-alive connection. Default:5.max_pipeline_length- Maximum pipelined requests per connection. Default:2.pipeline_timeout- Idle time before closing a pipelined connection, in milliseconds. Default:0(pipelining disabled). Pipelining is only used when this is set to a positive value.keep_alive_timeout- Idle time before closing a persistent connection. Default:120000ms (2 minutes).
For a service making requests to many different hosts, set
max_connections_open to a value appropriate for your system's file
descriptor limits.
Cookie Handling
Cookies are disabled by default ({cookies, disabled}). If you enable them,
prefer verify mode, which lets you inspect cookies before they are stored
via httpc:store_cookies/3:
httpc:set_options([{cookies, verify}]).Proxy Configuration
When using a proxy, ensure that the no_proxy list excludes internal hosts
that should not be routed through the proxy:
httpc:set_options([
{proxy, {{"proxy.example.com", 8080},
["localhost", "*.internal.example.com"]}}
]).For HTTPS traffic through a proxy, configure https_proxy separately. The
client uses the HTTP CONNECT method to establish a tunnel through the proxy.
By default, https_proxy inherits the value of proxy.
Sensitive Options
Warning
The following options can introduce injection, file overwrite, or data leakage vulnerabilities if misused.
Per-request options:
{socket_opts, Opts}- Passed directly to the transport layer without validation. Avoid exposing this to untrusted input.{headers_as_is, true}- Bypasses header normalization. This can enable header injection if header values come from untrusted sources.{stream, {self, once}}or{stream, Filename}- Streaming to a file path could overwrite files. Validate paths before use.
Per-profile options:
{verbose, debug | trace}- May log sensitive data such as headers containing authorization tokens.
HTTP Server (httpd)
Enable TLS
By default, httpd listens in plaintext ({socket_type, ip_comm}). For any
deployment beyond localhost, enable TLS:
httpd:start_service([{port, 8443},
{server_root, "/var/www"},
{document_root, "/var/www/htdocs"},
{socket_type, {ssl, [{cert_keys, [#{certfile => "/path/to/cert.pem",
keyfile => "/path/to/key.pem"}]},
{versions, ['tlsv1.2', 'tlsv1.3']}]}}]).Bind Address
The default {bind_address, any} listens on all network interfaces. Restrict
this to the intended interface:
{bind_address, {127, 0, 0, 1}} %% Localhost onlyor
{bind_address, "192.168.1.10"} %% Specific interfaceRequest Size Limits
Several size limits default to nolimit, which allows arbitrarily large
requests that can exhaust memory. Set explicit limits:
[{max_uri_size, 8192}, %% 8 KB URI limit
{max_header_size, 10240}, %% 10 KB (already the default)
{max_body_size, 10_485_760}, %% 10 MB body limit
{max_content_length, 10_485_760}] %% 10 MB Content-Length checkmax_uri_size- Maximum URI length in bytes. Default:nolimit.max_header_size- Maximum total header size. Default:10240(10 KB).max_body_size- Maximum received body size during parsing. Default:nolimit.max_content_length- Rejects requests whoseContent-Lengthheader exceeds this value with a 413 response, before reading the body. Default:100_000_000(100 MB).max_client_body_chunk- When handling large PUT or POST bodies viamod_esi, setting this option enforces chunked delivery to the ESI callback. This prevents the server from buffering the entire request body in memory, which could be exploited to cause memory exhaustion.
Note
max_body_size and max_content_length serve different purposes.
max_content_length is a fast pre-check on the header value.
max_body_size limits the actual bytes received. Set both for
defense in depth.
Server Connection Limits
max_clients- Maximum simultaneous connections. Default:150. Tune this to your expected load and available resources.keep_alive- Controls whether persistent connections are used. Default:true. Disabling persistent connections (false) eliminates certain classes of connection-reuse attacks at the cost of performance.max_keep_alive_request- Maximum requests per persistent connection. Default:infinity. Set a finite value to prevent a single connection from monopolizing server resources:{max_keep_alive_request, 1000}keep_alive_timeout- Seconds before closing an idle persistent connection. Default:150.minimum_bytes_per_second- Closes connections that transfer data below this rate. Not set by default. Enable it to mitigate slow-rate DoS attacks (for example, Slowloris):{minimum_bytes_per_second, 100}
The following table summarizes when each limit is checked during the request lifecycle:
| Stage | Event | Limit checked |
|---|---|---|
| 1 | TCP connected | max_clients |
| 2 | Headers received | max_uri_size, max_header_size |
| 3 | Content-Length checked | max_content_length |
| 4 | Body received | max_body_size |
| 5 | Idle on keep-alive | keep_alive_timeout |
| 6 | Slow transfer | minimum_bytes_per_second |
Server Identity
The default {server_tokens, minimal} reveals the complete inets version
string in the Server response header (for example, inets/9.3.1). Despite
the name, minimal still includes the full version number. This helps
attackers identify known vulnerabilities. Reduce information disclosure:
{server_tokens, none} %% Omit the Server header entirelyor
{server_tokens, prod} %% Just "inets", no versionModule Chain
The {modules, ...} option controls which httpd modules are active. In OTP 29,
mod_cgi and mod_actions are deprecated and removed from the default module
list. The default is:
[mod_alias, mod_auth, mod_esi,
mod_dir, mod_get, mod_head, mod_log, mod_disk_log]Warning
In OTP 28 and earlier, the default module list also includes mod_cgi and
mod_actions, enabling CGI execution out of the box. If you are running an
older release, removing these modules manually is especially important.
Review this list and remove modules you do not need:
mod_cgiandmod_actions- Enable CGI script execution. Deprecated in OTP 29 and scheduled for removal in OTP 30. If you still need CGI support, add them explicitly to your module list. CGI introduces a large attack surface (arbitrary process execution, environment variable injection).mod_dir- Enables directory listing. Remove it to prevent information disclosure about your file structure.mod_esi- Enables Erlang Scripting Interface. If used, restrict it witherl_script_aliasto a whitelist of allowed modules.mod_trace- Handles HTTP TRACE requests. TRACE can be exploited in cross-site tracing (XST) attacks. This module is not in the default list but should never be added in production.
A minimal module chain for a static file server:
{modules, [mod_alias, mod_get, mod_head, mod_log]}Authentication (mod_auth)
If using mod_auth:
Avoid
{auth_type, plain}in production. It stores passwords in cleartext files. Preferdetsormnesia.Place auth files outside
document_root. Theauth_user_fileandauth_group_filemust not be accessible via HTTP.Set
auth_access_passwordto a strong value. When not set or set to"NoPassword", no password is required for the authentication management API.Use IP-based restrictions (
allow_from,deny_from) as an additional layer, not as the sole access control mechanism.
Warning
The mod_auth module implements HTTP Basic Authentication, which transmits
credentials in base64 encoding (effectively cleartext). Always use TLS
when authentication is enabled.
Brute Force Protection (mod_security)
mod_security acts as a filter on top of mod_auth, tracking failed login
attempts per user and temporarily blocking users who exceed a threshold. This
mitigates credential-stuffing and brute-force password guessing attacks.
Enable mod_security to throttle authentication brute force attempts:
{security_directory, {"/protected", [
{data_file, "/var/lib/httpd/security.dat"},
{max_retries, 3},
{block_time, 60}, %% Block for 60 minutes
{fail_expire_time, 30}, %% Remember failures for 30 minutes
{auth_timeout, 30}
]}}data_file- Path to the persistent security data file. Store this outsidedocument_root. Required formod_securityto persist blocked-user state across server restarts.max_retries- Maximum failed authentication attempts before the user is blocked. Default:3.block_time- Minutes a blocked user remains locked out. Default:60.fail_expire_time- Minutes before a failed attempt is forgotten. If the user does not retry within this window, the failure counter resets. Default:30.auth_timeout- Seconds a successful authentication is remembered. After expiry the user must re-authenticate. Default:30.
Warning
mod_security must appear after mod_auth in the module chain.
It relies on mod_auth to perform the actual authentication; mod_security
only observes the results and enforces blocking policy.
For runtime inspection and manual blocking, see mod_security
(list_blocked_users/1, block_user/5, unblock_user/4).
CGI and ESI Execution
script_aliasmaps URL paths to CGI script directories. Ensure the mapped directory contains only intended scripts and is not writable by the web server process.erl_script_aliascontrols which Erlang modules can be called via ESI. Always specify an explicit whitelist:{erl_script_alias, {"/esi", [my_allowed_module]}}Never use a wildcard or overly broad module list.
script_timeoutanderl_script_timeoutdefault to 15 seconds. Review whether this is appropriate for your use case.script_nocacheanderl_script_nocache- When set totrue, the server adds HTTP header fields preventing proxies from caching dynamic responses. Default:false. Enable these to prevent stale or sensitive dynamic content from being served from proxy caches.
Warning
The script_alias path resolution can bypass mod_auth directory
protections depending on module ordering. Ensure mod_auth appears
before mod_cgi in the module chain, and test that authentication
is enforced on CGI paths.
Logging
Enable logging to detect and investigate security incidents:
[{error_log, "/var/log/httpd/error.log"},
{security_log, "/var/log/httpd/security.log"},
{transfer_log, "/var/log/httpd/access.log"},
{log_format, combined}] %% Includes referer and user-agent (default: common)For production systems, the disk_log variants (transfer_disk_log,
error_disk_log, security_disk_log) are recommended as they support wrap
logs with configurable size limits, preventing log files from consuming all
available disk space.
For integration with the OTP logger framework, where my_httpd is the
ServerID atom used in the logger domain hierarchy
[otp, inets, httpd, ServerID, error]:
{logger, [{error, my_httpd}]}Ensure log files are rotated and that log directories are not writable by the web server process.
Hardened Example
The following is a complete example combining the recommendations above for an httpd deployment serving static files with TLS:
httpd:start_service([
{port, 8443},
{bind_address, {127, 0, 0, 1}},
{server_root, "/var/www"},
{document_root, "/var/www/htdocs"},
{server_tokens, none},
{socket_type, {ssl, [{cert_keys, [#{certfile => "/path/to/cert.pem",
keyfile => "/path/to/key.pem"}]},
{versions, ['tlsv1.2', 'tlsv1.3']}]}},
{modules, [mod_alias, mod_auth, mod_security, mod_get, mod_head, mod_log]},
{max_clients, 100},
{max_keep_alive_request, 1000},
{keep_alive_timeout, 60},
{max_uri_size, 8192},
{max_header_size, 10240},
{max_body_size, 10_485_760},
{max_content_length, 10_485_760},
{minimum_bytes_per_second, 100},
{error_log, "/var/log/httpd/error.log"},
{transfer_log, "/var/log/httpd/access.log"},
{log_format, combined}
]).And a hardened httpc request:
HttpOpts = [{timeout, 30000},
{connect_timeout, 5000},
{autoredirect, false},
{autoretry, 10000},
{ssl, [{verify, verify_peer},
{cacerts, public_key:cacerts_get()},
{versions, ['tlsv1.2', 'tlsv1.3']}]}],
httpc:request(get, {"https://example.com", []}, HttpOpts, []).General Recommendations
Defense in Depth
The inets application provides application-level security controls. For
production deployments, combine these with:
- Network-level controls - Firewalls, network segmentation, and rate limiting.
- OS-level controls - Run the BEAM VM as an unprivileged user. Use file system permissions to protect configuration files, log files, and credential stores.
- Reverse proxy - Consider placing
httpdbehind a dedicated reverse proxy (such as Nginx or HAProxy) that provides additional request filtering, rate limiting, and TLS termination.
Monitoring
Use the OTP logger framework to monitor for:
- Repeated authentication failures (potential brute-force attacks).
- Unusual request patterns (potential scanning or fuzzing).
- Connection limit exhaustion (potential DoS).
Keep Up to Date
Security vulnerabilities are fixed in new releases of Erlang/OTP. Monitor the Erlang/OTP releases and apply updates promptly.
Changes from OTP 28
If you are upgrading from OTP 28 or running an older release, be aware of the following differences:
httpc
autoretrynot available before OTP 28.4 - The{autoretry, timeout()}option forhttpcwas introduced in OTP 28.4. On older releases, the client still retries on 503 withRetry-After, but only honors values of 99 seconds or less (hardcoded). The new option allows configuring this limit or disabling retries entirely with{autoretry, 0}.max_connections_opennot available before OTP 29 - The{max_connections_open, integer()}profile option forhttpcwas introduced in OTP 29. On older releases, there is no global limit on the number of open handlers.TLS verification default -
httpchas verified server certificates by default since OTP 26.0. On releases before 26.0, you must pass{ssl, httpc:ssl_verify_host_options(true)}explicitly or connections will proceed without certificate verification.
httpd
mod_cgiandmod_actionsin default modules - In OTP 28 and earlier, the default{modules, ...}list includesmod_cgiandmod_actions, enabling CGI execution by default. In OTP 29 these modules are deprecated and removed from the defaults. On older releases, remove them explicitly.