How to handle web requests in C with CGI 1.1
By Flavio Copes
Learn CGI 1.1 by building a C web application: read requests, decode forms, return HTML and errors, and run the executable through Apache.
A C program can handle a web request by reading environment variables, reading a body from standard input, and writing a response to standard output.
The web server connects those pieces. It receives the browser’s request, runs your program, and sends the result back.
That interface is CGI, the Common Gateway Interface.
Corey Prophitt’s A Modern Web App in C describes shipping a commercial application this way. His example uses OpenBSD, C, httpd, and SQLite. Here we’ll build a smaller program so we can see how the interface works.
We’ll start with a greeting, add query parameters and a form, then run the compiled executable through Apache. Along the way we’ll cover the CGI/1.1 contract, including the parts that cause broken responses or unsafe input handling.
You need some familiarity with C functions, strings, and pointers. The C Guide covers those prerequisites. If HTTP is new to you, start with the free HTTP course.
CGI 1.1 and the cgi-bin directory
The specification is called CGI/1.1, documented in RFC 3875. It was published in 2004 as an Informational RFC, documenting an existing interface.
cgi-bin is a conventional directory name. There is no separate “CGI-BIN 1.1” specification.
A server might map /cgi-bin/hello.cgi to an executable on disk. Another server could expose the same executable at /hello. The URL and directory layout belong to the server configuration.
“CGI script” is also a historical name. A compiled C executable is a CGI program just as much as an interpreted script.
CGI defines the handoff between a server and a program. HTTP defines the exchange between a browser and the server. A CGI/1.1 program does not require the browser to use HTTP/1.1.
What happens during a request?
Imagine opening this URL:
http://127.0.0.1:8080/cgi-bin/hello.cgi?name=Flavio
On a typical Unix CGI setup, the server starts a process for this request. It provides request metadata through environment variables, connects the request body to the program’s standard input, and collects standard output.
The program handles the request and exits. A later request starts another process. This is the model we’ll use with Apache; the specification itself describes an interface rather than requiring every platform to use Unix processes.
The flow looks like this:
Browser -- HTTP request --> Apache
|
CGI program
environment: metadata
stdin: request body
stdout: response
stderr: diagnostics
|
Browser <-- HTTP response -- Apache
Apache owns the connection and handles the HTTP transport. Our program works with the input and output streams Apache provides.
The server parses the request line and headers before invoking our program. stdin contains the request body, when there is one.
Write the smallest CGI program
On macOS, install Apple’s command line developer tools if cc is missing:
xcode-select --install
On Omarchy, install the C compiler tools and Apache:
omarchy pkg add base-devel apache
We’ll use a disposable directory for the executable and local server configuration:
mkdir -p /tmp/cgi-c-tutorial/cgi-bin /tmp/cgi-c-tutorial/www
cd /tmp/cgi-c-tutorial
Save this first program as /tmp/cgi-c-tutorial/hello.c:
#include <stdio.h>
int main(void) {
printf("Content-Type: text/plain; charset=utf-8\r\n\r\n");
printf("Hello from C!\n");
return 0;
}
Compile it and run it:
cc -std=c11 -Wall -Wextra -Wpedantic hello.c -o cgi-bin/hello.cgi
./cgi-bin/hello.cgi
The compiler creates an executable. .cgi is a useful filename convention; it does not change how C compiles the program.
The output has a header, a blank line, and a body:
Content-Type: text/plain; charset=utf-8
Hello from C!
The blank line separates headers from the body. In the C string, \r\n\r\n creates that separation. RFC 3875 permits the system’s newline convention for ordinary CGI output; we’ll use CRLF explicitly.
This first program only demonstrates output. The complete program below also checks methods and handles HEAD correctly.
CGI output becomes an HTTP response
For a normal document response, the program writes a Content-Type header. It can also write Status to choose the HTTP status code.
For example, this is the output for a missing page:
Status: 404 Not Found
Content-Type: text/plain; charset=utf-8
No page exists here.
Apache parses those headers and builds the HTTP response. When Status is absent, a document response defaults to 200 OK.
The C process’s exit status is separate. return 404 does not send an HTTP 404. We return successful process completion after producing a valid error response, and put the HTTP error in Status.
Ordinary CGI output starts with headers. The server supplies the HTTP status line. A special mode called NPH, or non-parsed headers, gives the program responsibility for a complete HTTP response. It needs server-specific setup and is outside this example.
Keep diagnostics on stderr:
fprintf(stderr, "Could not read the request body\n");
A debug message printed before your headers can make the entire response invalid. A message printed after the blank line becomes part of the page.
Read request metadata from the environment
CGI calls its request metadata meta-variables. On Unix, the server exposes them as environment variables, which C reads with getenv().
Here is the basic pattern:
const char *method = getenv("REQUEST_METHOD");
if (method == NULL) {
method = "";
}
getenv() returns a pointer to an existing string, or NULL if the variable is absent. Check it before calling functions such as strcmp() or strlen(). Copy the string if you need to modify it.
The standard variables are described in RFC 3875, section 4.1:
| Variable | What it tells your program |
|---|---|
GATEWAY_INTERFACE | The CGI interface version, normally CGI/1.1 |
REQUEST_METHOD | The method, such as GET, HEAD, or POST |
QUERY_STRING | The encoded query after ?, without the question mark |
CONTENT_LENGTH | The request body’s length in bytes, when supplied |
CONTENT_TYPE | The request body’s media type, when supplied |
SCRIPT_NAME | The URL path identifying the CGI program |
PATH_INFO | Additional path information following the script path |
PATH_TRANSLATED | A server-provided filesystem translation of PATH_INFO, when available |
SERVER_PROTOCOL | The request protocol name and version reported by the server |
SERVER_NAME | The server hostname or address used for this request |
SERVER_PORT | The server port for the request |
SERVER_SOFTWARE | The server software identifier |
REMOTE_ADDR | The client’s network address as seen by the server |
REMOTE_HOST | The client’s hostname, if the server provides one |
AUTH_TYPE | The authentication scheme, when the server authenticates the request |
REMOTE_USER | The server-authenticated user, when available |
REMOTE_IDENT | A legacy identification value; unsuitable for authentication |
Some variables are conditional. An unauthenticated request need not provide REMOTE_USER, for example. QUERY_STRING is defined as empty when there is no query. Our helper also tolerates it being absent when we test from a shell.
Servers can add extensions such as REQUEST_URI or HTTPS. Check your server’s documentation before relying on them; they are outside the standard CGI/1.1 variable set.
Request headers
Servers commonly expose request headers with an HTTP_ prefix. Names become uppercase, with hyphens replaced by underscores:
Accept: text/html -> HTTP_ACCEPT
User-Agent: curl -> HTTP_USER_AGENT
Cookie: session=abc -> HTTP_COOKIE
The body length and type use CONTENT_LENGTH and CONTENT_TYPE. Use those dedicated variables when reading the body.
Header exposure depends on the server. Sensitive headers may be withheld, and repeated headers may be combined. Don’t assume the environment preserves every header exactly as the client sent it.
A header value also remains untrusted input. A User-Agent string is not safe HTML, and a forwarded address is not a verified identity. Behind a proxy, REMOTE_ADDR may identify the proxy.
Query strings, request bodies, and paths
These are different inputs, even when they contain similar-looking text.
For this URL, QUERY_STRING contains name=Flavio:
/cgi-bin/hello.cgi?name=Flavio
A POST request can also have a query string. Its body arrives separately through stdin. The method alone does not determine where every parameter lives.
We’ll use query parameters for GET and HEAD, and the body for POST. Our POST handler deliberately ignores query parameters, so the precedence is explicit.
Form encoding
A normal HTML form without file uploads sends application/x-www-form-urlencoded data. In that format, fields are separated by &, and each field has a name and value separated by =.
For example:
name=Flavio+Copes
The + represents a space. %HH represents an encoded byte, so %26 is & and %2B is a literal plus sign.
Split the encoded fields first, then decode their names and values. If you decode the whole string before splitting, an encoded ampersand can become a false field separator. The URL Standard defines the format.
Our parser accepts one field named name. GET and HEAD also allow an empty query or an empty name, which displays a greeting for “visitor.” POST requires a nonempty name.
It rejects malformed escapes, unknown fields, duplicate names, and empty field segments. Those are application rules: a general form parser needs to support more cases, including repeated fields.
We’ll also restrict the decoded name to 80 printable ASCII characters. That keeps character handling visible in the example. A real form for people’s names needs UTF-8 support and a deliberate length policy. CGI itself does not impose our ASCII restriction.
Additional path information
A larger application could route requests through one executable:
/cgi-bin/app.cgi/notes/42?view=compact
With a server configured to accept that path, the split is:
SCRIPT_NAME = /cgi-bin/app.cgi
PATH_INFO = /notes/42
QUERY_STRING = view=compact
PATH_INFO has already been decoded by the server. Avoid decoding it again. In particular, never concatenate it onto a filesystem path and assume it is safe.
PATH_TRANSLATED is the server’s filesystem translation, when provided. It does not authorize your program to read that file.
Our greeting program has one endpoint, so it returns 404 when additional path information is present.
Read exactly the advertised body length
For POST, the program reads the body from stdin.
Validate CONTENT_LENGTH first. It is a decimal byte count, and it needs an application limit before it can determine a buffer size or a read.
Our limit is 4096 bytes. The final program checks every digit and checks the limit while constructing the number. That prevents integer overflow as well as oversized requests.
Then it reads until it has exactly that many bytes, or detects an incomplete body. fread() returns the number of bytes read when its element size is 1:
size_t count = fread(buffer, 1, bytes_needed, stdin);
Do not read until EOF. The server is not required to close the input stream immediately after the body. Reading beyond CONTENT_LENGTH violates the CGI input contract and can block.
The server also handles HTTP transfer coding before delivering the body. Your CGI program does not parse chunk framing. See RFC 3875, section 4.2.
The extra buffer byte holds the C string terminator. It is outside the body length. We reject embedded zero bytes because the form parser uses C strings.
JSON and file uploads use the same CGI input channel, but different parsers. Check CONTENT_TYPE: JSON needs a JSON parser, and multipart/form-data needs a multipart parser. Our handler returns 415 for either format.
Build the complete greeting program
We’ll now replace hello.c with a program that displays a greeting and an HTML form.
A GET query supplies the initial name. Submitting the form sends the name in a POST body. Nothing is saved between requests.
The response escapes the name before inserting it into HTML. This is separate from URL decoding: decoding recovers the submitted value, while HTML escaping keeps that value from becoming markup.
Save this complete file as /tmp/cgi-c-tutorial/hello.c:
#include <ctype.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#define MAX_INPUT 4096
#define MAX_NAME 80
static const char *env_value(const char *key) {
const char *value = getenv(key);
return value ? value : "";
}
static void headers(const char *status, const char *type) {
printf("Status: %s\r\n", status);
printf("Content-Type: %s\r\n", type);
printf("Cache-Control: no-store\r\n");
printf("X-Content-Type-Options: nosniff\r\n");
if (strcmp(status, "405 Method Not Allowed") == 0) {
printf("Allow: GET, HEAD, POST\r\n");
}
printf("\r\n");
}
static int error_response(int head, const char *status,
const char *message) {
headers(status, "text/plain; charset=utf-8");
if (!head) printf("%s\n", message);
return EXIT_SUCCESS;
}
static int hex_value(unsigned char c) {
if (c >= '0' && c <= '9') return c - '0';
if (c >= 'a' && c <= 'f') return c - 'a' + 10;
if (c >= 'A' && c <= 'F') return c - 'A' + 10;
return -1;
}
static int decode(char *text) {
char *read = text;
char *write = text;
while (*read) {
unsigned char c = (unsigned char)*read++;
if (c == '+') {
c = ' ';
} else if (c == '%') {
if (!read[0] || !read[1]) return 0;
int high = hex_value((unsigned char)read[0]);
int low = hex_value((unsigned char)read[1]);
if (high < 0 || low < 0) return 0;
c = (unsigned char)(high * 16 + low);
read += 2;
}
if (c < 32 || c > 126) return 0;
*write++ = (char)c;
}
*write = '\0';
return 1;
}
static int parse_name(char *input, char name[MAX_NAME + 1]) {
int seen = 0;
char *part = input;
while (*part) {
char *next = strchr(part, '&');
if (next) *next++ = '\0';
char *equals = strchr(part, '=');
if (!equals) return 0;
*equals++ = '\0';
if (!decode(part) || !decode(equals)) return 0;
if (strcmp(part, "name") != 0 || seen) return 0;
size_t length = strlen(equals);
if (length > MAX_NAME) return 0;
memcpy(name, equals, length + 1);
seen = 1;
if (!next) break;
if (!*next) return 0;
part = next;
}
return 1;
}
static int form_content_type(const char *type) {
const char *expected = "application/x-www-form-urlencoded";
size_t length = strcspn(type, ";");
while (length && (type[length - 1] == ' ' || type[length - 1] == '\t')) {
length--;
}
if (length != strlen(expected)) return 0;
for (size_t i = 0; i < length; i++) {
if (tolower((unsigned char)type[i]) != expected[i]) return 0;
}
return 1;
}
static void html_text(const char *text) {
for (; *text; text++) {
switch (*text) {
case '&': fputs("&", stdout); break;
case '<': fputs("<", stdout); break;
case '>': fputs(">", stdout); break;
case '"': fputs(""", stdout); break;
case '\'': fputs("'", stdout); break;
default: putchar((unsigned char)*text);
}
}
}
int main(void) {
const char *method = env_value("REQUEST_METHOD");
int head = strcmp(method, "HEAD") == 0;
int post = strcmp(method, "POST") == 0;
char input[MAX_INPUT + 1];
char name[MAX_NAME + 1] = "";
if (!*method) {
return error_response(head, "500 Internal Server Error",
"Missing CGI request method.");
}
if (!head && !post && strcmp(method, "GET") != 0) {
return error_response(head, "405 Method Not Allowed",
"Use GET, HEAD, or POST.");
}
if (*env_value("PATH_INFO")) {
return error_response(head, "404 Not Found",
"This program has no additional routes.");
}
if (post) {
if (!form_content_type(env_value("CONTENT_TYPE"))) {
return error_response(head, "415 Unsupported Media Type",
"Send a URL-encoded form.");
}
const char *length_text = env_value("CONTENT_LENGTH");
if (!*length_text) {
return error_response(head, "411 Length Required",
"Missing body length.");
}
size_t length = 0;
for (const char *p = length_text; *p; p++) {
if (*p < '0' || *p > '9') {
return error_response(head, "400 Bad Request",
"Invalid body length.");
}
unsigned int digit = (unsigned int)(*p - '0');
if (length > (MAX_INPUT - digit) / 10) {
return error_response(head, "413 Content Too Large",
"The form is too large.");
}
length = length * 10 + digit;
}
size_t received = 0;
while (received < length) {
size_t count = fread(input + received, 1, length - received, stdin);
if (count == 0) {
fputs("CGI form body ended before CONTENT_LENGTH\n", stderr);
return error_response(head, "400 Bad Request",
"Incomplete form body.");
}
received += count;
}
if (memchr(input, '\0', length)) {
return error_response(head, "400 Bad Request",
"Invalid form data.");
}
input[length] = '\0';
} else {
const char *query = env_value("QUERY_STRING");
size_t length = strlen(query);
if (length > MAX_INPUT) {
return error_response(head, "414 URI Too Long",
"The query string is too large.");
}
memcpy(input, query, length + 1);
}
if (!parse_name(input, name) || (post && !*name)) {
return error_response(head, "400 Bad Request",
"Send one name field, up to 80 printable ASCII characters.");
}
headers("200 OK", "text/html; charset=utf-8");
if (head) return EXIT_SUCCESS;
fputs("<!doctype html><html lang=\"en\"><head>"
"<meta charset=\"utf-8\"><title>C CGI greeting</title>"
"</head><body><h1>Hello, ", stdout);
html_text(*name ? name : "visitor");
fputs("!</h1><form method=\"post\">"
"<label>Your name <input name=\"name\" maxlength=\"80\" required></label>"
"<button>Say hello</button></form></body></html>\n", stdout);
return EXIT_SUCCESS;
}
The helpers each cover one part of the request. decode() processes form encoding in place. parse_name() splits fields and validates our application’s shape. html_text() escapes values placed in HTML text.
form_content_type() compares the media type without case sensitivity and permits a parameter suffix, such as ; charset=UTF-8. It does not parse those parameters; the accepted data remains ASCII.
The HEAD branch validates the same query as GET and produces the same status and response headers, then stops before writing the body. HTTP HEAD semantics require a response without content.
We omit response Content-Length, which is optional here. If you add it, calculate the length of the encoded response body, including HTML escapes. For HEAD, use the length the corresponding GET would have sent, rather than zero.
The error paths return text and an appropriate status. A 405 response also includes Allow, listing our supported methods. Server-side timeouts still matter: a byte limit cannot stop a client from sending those bytes slowly.
Test the executable without a server
Compile the replacement file:
cd /tmp/cgi-c-tutorial
cc -std=c11 -Wall -Wextra -Wpedantic hello.c -o cgi-bin/hello.cgi
You can supply the CGI environment yourself. These commands use env, so they also work in fish:
env REQUEST_METHOD=GET QUERY_STRING='name=Flavio+Copes' ./cgi-bin/hello.cgi
The response should contain Hello, Flavio Copes!.
For POST, pipe the body into the process. name=Flavio is exactly 11 bytes, and printf adds no newline:
printf '%s' 'name=Flavio' | env REQUEST_METHOD=POST CONTENT_TYPE=application/x-www-form-urlencoded CONTENT_LENGTH=11 ./cgi-bin/hello.cgi
Try invalid input too:
env REQUEST_METHOD=GET QUERY_STRING='name=%ZZ' ./cgi-bin/hello.cgi
env REQUEST_METHOD=GET QUERY_STRING='name=Flavio&name=Anna' ./cgi-bin/hello.cgi
env REQUEST_METHOD=DELETE ./cgi-bin/hello.cgi
env REQUEST_METHOD=HEAD QUERY_STRING='name=Flavio' ./cgi-bin/hello.cgi
The first two return 400. DELETE returns 405 and an Allow header. HEAD returns headers followed by the blank line, with no HTML body.
A short body should fail too. This command declares 20 bytes but only supplies 11:
printf '%s' 'name=Flavio' | env REQUEST_METHOD=POST CONTENT_TYPE=application/x-www-form-urlencoded CONTENT_LENGTH=20 ./cgi-bin/hello.cgi
It returns 400 and writes a diagnostic to stderr.
These shell tests check the CGI side of the contract. We still need a real server to check the browser-to-server-to-program path.
Run it through Apache
We’ll use a separate local Apache configuration listening on 127.0.0.1:8080. It uses its own PID file and error log, and requires no root privileges.
The following configuration uses the Apache executable and modules bundled with macOS. Save it as /tmp/cgi-c-tutorial/httpd.conf:
ServerRoot "/usr"
LoadModule mpm_prefork_module libexec/apache2/mod_mpm_prefork.so
LoadModule unixd_module libexec/apache2/mod_unixd.so
LoadModule authz_core_module libexec/apache2/mod_authz_core.so
LoadModule alias_module libexec/apache2/mod_alias.so
LoadModule cgi_module libexec/apache2/mod_cgi.so
Listen 127.0.0.1:8080
ServerName localhost
DefaultRuntimeDir "/tmp/cgi-c-tutorial"
PidFile "/tmp/cgi-c-tutorial/httpd.pid"
ErrorLog "/tmp/cgi-c-tutorial/error.log"
DocumentRoot "/tmp/cgi-c-tutorial/www"
<Directory "/tmp/cgi-c-tutorial">
AllowOverride None
Options None
Require all granted
</Directory>
ScriptAlias "/cgi-bin/" "/tmp/cgi-c-tutorial/cgi-bin/"
ScriptAlias maps /cgi-bin/ to the executable directory and marks its contents as CGI programs. Keep hello.c outside that directory. Apache should only find executables there. The Apache CGI tutorial explains this mapping.
This configuration deliberately loads the prefork multiprocessing module with mod_cgi. Apache’s threaded configurations use mod_cgid, which manages CGI execution through a separate daemon.
On Omarchy, change ServerRoot to /etc/httpd and use these module paths in place of the five macOS LoadModule lines:
LoadModule mpm_prefork_module /usr/lib/httpd/modules/mod_mpm_prefork.so
LoadModule unixd_module /usr/lib/httpd/modules/mod_unixd.so
LoadModule authz_core_module /usr/lib/httpd/modules/mod_authz_core.so
LoadModule alias_module /usr/lib/httpd/modules/mod_alias.so
LoadModule cgi_module /usr/lib/httpd/modules/mod_cgi.so
The rest of the configuration stays the same. If your Apache package uses different module locations, use its installed paths. You can inspect build settings with httpd -V.
On macOS, check the configuration and start Apache in the foreground:
/usr/sbin/httpd -t -f /tmp/cgi-c-tutorial/httpd.conf
/usr/sbin/httpd -f /tmp/cgi-c-tutorial/httpd.conf -DFOREGROUND
On Omarchy, use httpd in place of /usr/sbin/httpd.
Keep that terminal open. Open the local greeting page in your browser, enter a name, and submit the form.
The browser sends POST data. Apache runs hello.cgi, which reads that data and returns a new page with your greeting. Stop the local server with Control-C when you’re finished.
Check requests with curl
In another terminal, inspect a GET response and submit a form:
curl -i 'http://127.0.0.1:8080/cgi-bin/hello.cgi?name=Flavio'
curl -i --data-urlencode 'name=Flavio & friends' http://127.0.0.1:8080/cgi-bin/hello.cgi
--data-urlencode encodes the ampersand as part of the name. The resulting page displays it as text.
Inspect HEAD and method errors:
curl -I 'http://127.0.0.1:8080/cgi-bin/hello.cgi?name=Flavio'
curl -i -X DELETE http://127.0.0.1:8080/cgi-bin/hello.cgi
You can also test escaping by opening this query:
curl -i 'http://127.0.0.1:8080/cgi-bin/hello.cgi?name=%3Cscript%3Ealert(1)%3C%2Fscript%3E'
The HTML contains <script>, and the browser displays the submitted text. It must never create a script element from the name.
Redirects have special rules
CGI/1.1 distinguishes document responses and several redirect forms. The details are in RFC 3875, section 6.2.
For a client redirect after saving a form, you can produce a redirect response with a short document:
printf("Status: 303 See Other\r\n");
printf("Location: https://flaviocopes.com/\r\n");
printf("Content-Type: text/plain; charset=utf-8\r\n\r\n");
printf("Continue at https://flaviocopes.com/\n");
The absolute URL makes the redirect a client redirect. A 303 tells the browser to retrieve the destination after the POST. This pattern is useful once a form changes persistent state, because refreshing the destination doesn’t repeat the submission.
A CGI client redirect containing only an absolute Location header and a blank line lets the server generate a 302 response instead.
There is also a local redirect. This output asks the server to process another local resource internally:
Location: /courses/http/
The blank line is required. For this bare local redirect, emit no other headers or body. The browser need not see a redirect response at all, and the address bar can stay unchanged.
That is why swapping an absolute URL for /courses/http/ is not an interchangeable CGI redirect pattern. Follow your server’s documented behavior when you want a browser redirect to a local destination.
Never copy an unvalidated request value into Location or another response header. It can create an open redirect or inject header lines.
What CGI leaves to your application
CGI delivers a request and collects a response. It doesn’t supply sessions, form parsing, database queries, or a template engine.
In a C application, you choose how to implement those pieces.
For persistence, SQLite is one option. A process can open a database file for its request and close it before exiting. Keep the file outside the executable directory and document root. Concurrent requests still need a deliberate transaction and locking strategy.
Process globals disappear when a classic CGI request ends. A global counter therefore cannot store visits across requests, and a session must live somewhere persistent.
Cookies come in through the request’s cookie header. Responses set them with Set-Cookie. Cookie parsing and session validation need more than splitting a string: use unpredictable session identifiers, appropriate expiry, and HTTPS cookie attributes. The free browser storage course covers the browser side.
For forms that change saved data, add authorization and CSRF protection. Our greeting form has no persistent effect, so it only demonstrates input handling.
C safety and output contexts
The final program uses bounded arrays and checks lengths before copying. Keep that habit when you add fields. A server does not make strcpy() safe just because it already parsed HTTP.
html_text() is for values inserted into HTML text. Other contexts need their own handling: JSON needs JSON encoding, URLs need component encoding and validation, and database values need parameterized queries.
Avoid passing request values to system() or constructing shell commands from them. Keep filesystem access inside a fixed application boundary. Run the executable as an unprivileged account, and make its directory writable only by whoever deploys the application.
For a larger C web application, I would look at kcgi, which supports CGI and FastCGI and provides request-processing facilities. Our deliberately narrow parser is useful for learning the interface; it doesn’t cover general forms, Unicode validation, cookies, or uploads.
You can compile with sanitizers while testing on supported compiler toolchains:
cc -std=c11 -g -Wall -Wextra -Wpedantic -fsanitize=address,undefined hello.c -o cgi-bin/hello-sanitized.cgi
env REQUEST_METHOD=GET QUERY_STRING='name=%' ./cgi-bin/hello-sanitized.cgi
Keep testing malformed input as you add routes. A sanitizer can find memory errors, but it cannot tell you whether a user is authorized to read a record.
How does this compare with a web framework?
For a small C tool, CGI can give you a web interface with very little infrastructure. As the application grows, you spend more time building the conveniences a framework would have supplied.
There are separate choices here. C is the language. CGI is the interface to the web server. A framework supplies application tools and conventions. A C application can use a framework, and using CGI doesn’t require you to write every helper yourself.
For this comparison, think of our C executable beside an application built with a full framework such as Django or Rails. A smaller framework such as Express provides fewer features itself, so more of the work depends on the libraries you choose.
| Area | Our C program with classic CGI | A typical application using a full web framework |
|---|---|---|
| Deployment | Install the binary and configure the web server | Install the application and runtime, and configure its serving environment |
| Request lifetime | Start and initialize a process for each request | Commonly reuse application workers across requests |
| Forms and output | Choose libraries or implement parsing, validation, and escaping | Use existing request, form, and template facilities |
| Product features | Build or integrate sessions, authentication, and migrations | Many common features already have framework support |
| Dependencies | A small program can keep its dependency set narrow | The framework and selected packages need maintenance |
| Collaboration | The team needs to understand the C code and its conventions | Shared framework conventions help developers find their way around |
What you gain with CGI in C
Deployment can be small. Our example needs the executable and Apache configuration. A compiled C program needs no language interpreter, although a dynamically linked build still needs compatible runtime libraries on the host.
With classic CGI, there’s no separate application daemon to supervise. You can replace the executable for new requests without restarting a persistent application worker. Database migrations, assets, and configuration still need their own deployment plan.
The request lifecycle is easy to inspect. You can reproduce it with env and a pipe, as we did above. When the process exits, the operating system reclaims its memory. A leak inside one request cannot keep accumulating in that same process across thousands of later requests.
That doesn’t remove the leak’s cost during a long request, or isolate shared files and the database. Separate processes also aren’t a security sandbox by themselves.
You control the code between the request and the response. If you already have a C library that performs an expensive calculation, CGI gives you a direct way to expose it through a form. You can keep that library in the same program and choose only the web-related pieces you need.
A narrow dependency set can reduce the amount of third-party code you maintain. The benefit depends on what you build: adding payment clients, image processing, and authentication can grow a C application’s dependencies too. Your own replacement code also needs review and maintenance.
What a framework saves you
Our greeting program spends most of its code handling input and output. The actual greeting takes only a few lines.
Now imagine adding accounts, password resets, file uploads, and an administration interface. Each feature needs a design, error handling, and tests. Writing all of that in C can take much longer than connecting existing framework facilities.
For example, Django provides routing, templates, an ORM, database migrations, and an admin interface. Those features give you a starting point for a database-backed application. You still write the product’s rules, but you spend less time building common web infrastructure.
Frameworks also provide security mechanisms. Django documents template escaping, CSRF protection, and parameterized queries. You need to use them correctly, configure deployment safely, and keep dependencies updated. They cannot decide whether a particular user should be allowed to edit a particular record.
In C, an incorrect buffer length or a pointer mistake can become a memory safety vulnerability. Choosing CGI doesn’t prevent those mistakes. The bounds checks and sanitizers in this tutorial help, but a larger program has more code to get right.
Framework conventions help with maintenance too. Another developer familiar with the framework can recognize routes, models, and templates. In a custom C application, you need to document where those responsibilities live and how to extend them safely.
The framework has costs of its own. You learn its conventions, maintain its dependencies, and adapt when its APIs change. For an application with one form around an existing C tool, those costs can be more work than the application needs.
Performance needs a workload
Compiled C can make CPU-intensive work efficient. Classic CGI adds process startup and repeated initialization to each request. These costs need to be measured together.
A persistent framework application can reuse initialized code and, depending on its configuration, database connections or pools. A C CGI program normally has to set those up again. For a request that mainly waits on a database or an external API, the language may have little effect on the total response time.
Memory usage also depends on concurrency. A small CGI process can use little memory, but many simultaneous requests create many processes. Framework servers can use multiple workers too; compare the whole deployment at the same request rate.
Neither approach guarantees a faster or cheaper application. Test realistic requests, including startup, database access, and concurrent clients. FastCGI changes part of that comparison by keeping the C application alive, as we’ll see next.
I would choose CGI in C for a small interface around an existing C program, or when learning the HTTP boundary is part of the goal. For a growing product with accounts, uploads, and many screens, I would usually start with a framework. If one calculation needs C, the rest of the application can still use a framework and call that code through an appropriate integration.
CGI, FastCGI, and where the program runs
Classic CGI’s process-per-request model has a cost. The executable starts again, initializes its libraries, and reopens anything it needs for each request.
That can be acceptable for a small application. Benchmark your workload before making claims about capacity. Startup and repeated database setup can become significant, and long-running requests keep processes occupied.
FastCGI allows application processes to stay alive and handle multiple requests. It carries parameters, input, and output in protocol records over a connection. The FastCGI specification defines that separate protocol.
An ordinary CGI executable doesn’t become a persistent FastCGI service just by changing a server setting. You need an adapter or application support through a FastCGI library. Once processes persist, request state also needs resetting between requests.
Nginx provides FastCGI upstream support; it doesn’t directly run a classic CGI executable like our Apache setup. OpenBSD’s httpd is a different server from Apache’s httpd, with its own deployment setup. Check that server’s CGI/FastCGI integration before copying this configuration.
For a public deployment, choose a host where you can run and configure the server, such as a VPS. Put HTTPS at the front, configure request limits and timeouts, and send diagnostics to a managed log. A static website host cannot execute this C program for requests.
Deploy an updated executable by building a new file and replacing the old pathname atomically on the same filesystem. Avoid writing over a running binary in place. Also rebuild for the target operating system and CPU: the macOS executable from this tutorial won’t run on a Linux server.
I would use CGI for a small C tool that needs a web interface, especially when an existing server already supports it. For frequent requests with expensive initialization, I would compare a persistent server or FastCGI. For a WebSocket application, I would choose a server model built around long-lived connections.
When the program fails through the server
If it works from the terminal and fails through Apache, inspect the local error log:
tail -n 50 /tmp/cgi-c-tutorial/error.log
A 403 often points to directory access rules or permissions. The server account must be able to traverse the parent directories and execute the binary. Don’t solve that by making the executable directory writable by everyone.
A 500 with an error about script headers usually means the program crashed, printed a diagnostic on stdout, or failed to output complete headers and a blank line. Missing runtime libraries can also prevent a binary from starting.
If the server serves the file instead of executing it, fix the CGI mapping. Never place source files or secrets in a directory exposed as downloadable content.
A request that hangs often points to reading beyond the body length, or to a server timeout that is too generous for the application.
CGI programs can also have a different working directory and environment from your terminal. Use explicit paths for application files and check the server account’s permissions.
One historical feature can surprise you while debugging: CGI/1.1 permits command-line arguments for certain GET and HEAD queries containing no unencoded =. These “indexed queries” split words on +. Our program reads QUERY_STRING and uses int main(void), so it never depends on that legacy argument mechanism.
Once the greeting works through Apache, try adding /health and /greeting routes using PATH_INFO. Keep the same boundary: validate the request before sending headers, and escape each value for the output format you place it in.
Want me to talk about your product? You can sponsor this site.