How to handle web requests in C with CGI 1.1

By

Tagged: C

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. It reads environment variables, reads the body from standard input, and writes the response to standard output. The web server does the rest: 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, with OpenBSD, C, httpd and SQLite. Here we’ll build something much smaller so we can see how the interface works.

We’ll start with a greeting, add query parameters and a form, then run the compiled program through Apache. Along the way we’ll go through the CGI/1.1 contract, including the parts that break responses or let unsafe input through.

You need to know C functions, strings and pointers. The C Guide covers those. If HTTP is new to you, start with the free HTTP course.

CGI 1.1 and the cgi-bin directory

The spec is called CGI/1.1, and it’s RFC 3875, an Informational RFC published in 2004 to document an interface that already existed.

cgi-bin is just a conventional directory name. One server can map /cgi-bin/hello.cgi to an executable on disk, and another can expose the same executable at /hello. That’s server configuration.

“CGI script” is a historical name too. A compiled C executable is just as much a CGI program as a Perl script.

Also, CGI and HTTP are two separate handoffs. HTTP is between the browser and the server, CGI is between the server and your program, so a CGI/1.1 program doesn’t need the browser to speak HTTP/1.1.

What happens during a request?

Say you open this URL:

http://127.0.0.1:8080/cgi-bin/hello.cgi?name=Flavio

On a typical Unix setup, the server starts a new process for this request. It passes the request metadata in environment variables, connects the request body to the program’s standard input, and collects standard output. The program handles the request and exits, and the next request starts another process. (The spec describes an interface, so other platforms can do this differently, but that’s the model we’ll use with Apache.)

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 HTTP. It parses the request line and headers before running our program, and stdin only contains the body, when there is one.

Fig 1 · 1/4The CGI input and output streams
Receive HTTP

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 work in a throwaway directory for the executable and the 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 .cgi extension is just a naming convention, it doesn’t 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, and in the C string that’s the \r\n\r\n. RFC 3875 lets CGI output use the system’s newline convention, but we’ll use CRLF explicitly.

This program only shows the output format. The complete one later also checks the method and handles HEAD.

CGI output becomes an HTTP response

For a normal document, the program writes a Content-Type header. It can also write Status to pick the HTTP status code. 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. Without Status, you get 200 OK.

Be careful not to mix this up with the process exit status. return 404 doesn’t send an HTTP 404. We exit successfully after writing a valid error response, and put the HTTP error in Status.

The server supplies the HTTP status line. There’s a special mode called NPH (non-parsed headers) where the program writes the complete HTTP response itself, but it needs server-specific setup and we won’t use it.

Diagnostics go on stderr:

fprintf(stderr, "Could not read the request body\n");

A debug message printed before your headers breaks the whole response, and one printed after the blank line ends up in the page.

Read request metadata from the environment

CGI calls request metadata meta-variables. On Unix the server passes them as environment variables, and you read them in C with getenv():

const char *method = getenv("REQUEST_METHOD");
if (method == NULL) {
  method = "";
}

getenv() returns a pointer to an existing string, or NULL if the variable isn’t set. Check for NULL before calling strcmp() or strlen(), and copy the string if you need to modify it.

The standard variables are in RFC 3875, section 4.1:

VariableWhat it tells your program
GATEWAY_INTERFACEThe CGI interface version, normally CGI/1.1
REQUEST_METHODThe method, such as GET, HEAD, or POST
QUERY_STRINGThe encoded query after ?, without the question mark
CONTENT_LENGTHThe request body’s length in bytes, when supplied
CONTENT_TYPEThe request body’s media type, when supplied
SCRIPT_NAMEThe URL path identifying the CGI program
PATH_INFOAdditional path information following the script path
PATH_TRANSLATEDA server-provided filesystem translation of PATH_INFO, when available
SERVER_PROTOCOLThe request protocol name and version reported by the server
SERVER_NAMEThe server hostname or address used for this request
SERVER_PORTThe server port for the request
SERVER_SOFTWAREThe server software identifier
REMOTE_ADDRThe client’s network address as seen by the server
REMOTE_HOSTThe client’s hostname, if the server provides one
AUTH_TYPEThe authentication scheme, when the server authenticates the request
REMOTE_USERThe server-authenticated user, when available
REMOTE_IDENTA legacy identification value; unsuitable for authentication

Some of them only show up sometimes. An unauthenticated request has no REMOTE_USER, for example. QUERY_STRING is empty when there’s no query, and our helper also handles it being missing, which happens when we test from a shell.

Servers add their own extras, like REQUEST_URI or HTTPS. They’re not part of CGI/1.1, so check your server’s docs before you rely on them.

Request headers

Servers usually pass request headers with an HTTP_ prefix, uppercased, with hyphens turned into underscores:

Accept: text/html       -> HTTP_ACCEPT
User-Agent: curl       -> HTTP_USER_AGENT
Cookie: session=abc    -> HTTP_COOKIE

The body length and type are the exception: they come in CONTENT_LENGTH and CONTENT_TYPE, and that’s what you use to read the body.

Which headers you get depends on the server. Sensitive ones can be withheld and repeated ones combined, so don’t expect an exact copy of what the client sent.

Header values are untrusted input. A User-Agent string isn’t safe HTML, and a forwarded address isn’t a verified identity. Behind a proxy, REMOTE_ADDR is the proxy’s address.

Query strings, request bodies, and paths

These are separate inputs, even when they look alike. For this URL, QUERY_STRING contains name=Flavio:

/cgi-bin/hello.cgi?name=Flavio

A POST request can have a query string too, with the body arriving separately on stdin. So the method alone doesn’t tell you where the parameters are. In our program GET and HEAD read the query, and POST reads the body and ignores the query.

Form encoding

A normal HTML form without file uploads sends application/x-www-form-urlencoded data. Fields are separated by &, and each field is a name and a value separated by =:

name=Flavio+Copes

+ is a space, and %HH is an encoded byte, so %26 is & and %2B is a literal plus sign.

Split the fields first, then decode the names and values. If you decode the whole string first, an encoded ampersand turns into a fake field separator. The URL Standard defines the format.

Our parser accepts one field, name. GET and HEAD also accept an empty query or an empty name and greet a “visitor”, while POST needs a nonempty name. It rejects malformed escapes, unknown fields, duplicate names and empty segments. Those are our app’s rules, a general form parser would have to handle more, including repeated fields.

We also limit the decoded name to 80 printable ASCII characters, to keep the character handling easy to follow. A real form for people’s names needs UTF-8 support and a length limit you picked on purpose. CGI itself has no such restriction.

Decode form fields in the right orderSplit encoded form fields before decoding names and values, so an encoded ampersand stays inside its original field. Escape decoded values separately for HTML output.Encoded form dataSplit fields atampersandsSeparate each nameand valueDecode plus andpercent escapesValidate acceptedfieldsEscape values forHTML text

Additional path information

A bigger application could route all requests through one executable:

/cgi-bin/app.cgi/notes/42?view=compact

If the server is configured to accept that path, the split is:

SCRIPT_NAME = /cgi-bin/app.cgi
PATH_INFO = /notes/42
QUERY_STRING = view=compact

The server has already decoded PATH_INFO, so don’t decode it again. And never append it to a filesystem path assuming it’s safe.

PATH_TRANSLATED is the server’s filesystem translation, when it provides one. That doesn’t mean your program should read that file.

Our greeting program has one endpoint, so it returns 404 when there’s any extra path.

Read exactly the advertised body length

For POST, the program reads the body from stdin.

First validate CONTENT_LENGTH. It’s a decimal byte count, and before you use it to size a buffer or a read, it needs a limit. Ours is 4096 bytes. The final program checks every digit and checks the limit while building the number, which prevents integer overflow as well as oversized requests.

Then it reads until it has exactly that many bytes, or notices the body is incomplete. With an element size of 1, fread() returns the number of bytes read:

size_t count = fread(buffer, 1, bytes_needed, stdin);

Don’t read until EOF. The server doesn’t have to close the input right after the body, so reading past CONTENT_LENGTH breaks the CGI contract and can hang. The server also removes HTTP transfer coding before passing the body on, so you never parse chunk framing (RFC 3875, section 4.2).

The buffer has one extra byte for the C string terminator, outside the body length. We reject embedded zero bytes because the parser works with C strings.

JSON and file uploads come through the same stdin, but need different parsers: a JSON parser for JSON, a multipart parser for multipart/form-data. Our handler checks CONTENT_TYPE and returns 415 for both.

Build the complete greeting program

Now let’s replace hello.c with a program that shows a greeting and an HTML form. A GET query sets the initial name, and submitting the form sends the name in a POST body. Nothing is saved between requests.

The name is escaped before it goes into the HTML. That’s a different step from URL decoding: decoding gets back the submitted value, 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("&amp;", stdout); break;
      case '<': fputs("&lt;", stdout); break;
      case '>': fputs("&gt;", stdout); break;
      case '"': fputs("&quot;", stdout); break;
      case '\'': fputs("&#39;", 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;
}

Each helper handles one part of the request. decode() decodes form encoding in place, parse_name() splits fields and checks our application’s rules, and html_text() escapes values placed in HTML text.

form_content_type() compares the media type case-insensitively and allows a parameter suffix like ; charset=UTF-8. It doesn’t parse the parameters, and the accepted data is still ASCII.

The HEAD branch validates the same query as GET and sends the same status and headers, then stops before the body, because HTTP HEAD semantics require a response without content.

We leave out the response Content-Length, which is optional here. If you add it, count the encoded body, HTML escapes included, and for HEAD send the length the matching GET would have sent, not zero.

Every error path returns text with a fitting status, and a 405 also includes Allow with our supported methods. You still need server-side timeouts, since a byte limit can’t stop a client from sending those bytes very slowly.

Test the executable without a server

Compile the new file:

cd /tmp/cgi-c-tutorial
cc -std=c11 -Wall -Wextra -Wpedantic hello.c -o cgi-bin/hello.cgi

You can set 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

Now some invalid input:

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 with an Allow header. HEAD returns the headers and the blank line, with no HTML body.

A short body should fail too. This one declares 20 bytes and sends 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.

That covers our side of the contract. To test the full browser to server to program path, we need a real server.

Run it through Apache

We’ll run a separate local Apache on 127.0.0.1:8080 with its own PID file and error log, so it doesn’t need root.

This configuration uses the Apache executable and modules that ship 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 everything in it as a CGI program. That’s why hello.c stays outside it: Apache should only find executables there. The Apache CGI tutorial explains the mapping.

We load the prefork multiprocessing module with mod_cgi on purpose. Apache’s threaded configurations use mod_cgid instead, which runs CGI programs through a separate daemon.

On Omarchy, change ServerRoot to /etc/httpd and replace the five macOS LoadModule lines with these:

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 stays the same. If your Apache package puts modules somewhere else, use its paths, which httpd -V shows.

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 instead 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 the POST data, Apache runs hello.cgi, and the program returns a new page with your greeting. Press Control-C to stop the server when you’re done.

Check requests with curl

In another terminal, look at a GET response and submit the 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, and the page shows it as text.

Then HEAD and a method error:

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

And escaping:

curl -i 'http://127.0.0.1:8080/cgi-bin/hello.cgi?name=%3Cscript%3Ealert(1)%3C%2Fscript%3E'

The HTML contains &lt;script&gt;, so the browser shows the text instead of creating a script element.

Redirects have special rules

CGI/1.1 has document responses and several kinds of redirect, described in RFC 3875, section 6.2.

After saving a form, you’d send a client redirect 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 is what makes this a client redirect. The 303 tells the browser to load the destination with a GET after the POST, so refreshing that page doesn’t submit the form again. If you send only an absolute Location header and a blank line, the server generates a 302 instead.

There’s also a local redirect, which asks the server to process another local resource internally:

Location: /courses/http/

The blank line is required, and a bare local redirect has no other headers or body. The browser may never see a redirect, and the address bar stays the same. So swapping the absolute URL for /courses/http/ doesn’t give you the same behavior. If you want the browser to redirect to a local page, follow your server’s docs.

Never copy an unvalidated request value into Location or any other header. That’s how you get open redirects and injected header lines.

What CGI leaves to your application

CGI hands you a request and collects a response. Sessions, form parsing, database queries and templates are up to you.

For persistence, SQLite is a simple option: open the database file during the request and close it before exiting. Keep the file outside the executable directory and the document root, and decide how you handle transactions and locking, because concurrent requests will hit it at the same time.

Globals disappear when a classic CGI request ends, so a global counter can’t count visits, and sessions need to live somewhere persistent.

Cookies arrive in the cookie header, and you set them with Set-Cookie. Sessions need more than string splitting: unpredictable session IDs, sensible expiry, and HTTPS cookie attributes. The free browser storage course covers the browser side.

Forms that change saved data need authorization and CSRF protection. Our greeting form doesn’t save anything, it’s only there to show input handling.

C safety and output contexts

The program uses fixed-size arrays and checks lengths before copying. Keep doing that as you add fields. The server parsing HTTP for you doesn’t make strcpy() safe.

html_text() is only for HTML text. JSON needs JSON encoding, URLs need component encoding and validation, and database values need parameterized queries.

Don’t pass request values to system() or build shell commands from them. Keep file access inside a fixed directory, run the executable as an unprivileged user, and make its directory writable only by whoever deploys it.

For a larger C web application, I would look at kcgi, which supports CGI and FastCGI and handles request processing for you. Our parser is intentionally narrow, good for learning, but it doesn’t handle general forms, Unicode, cookies or uploads.

While testing, compile with sanitizers if your toolchain supports them:

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 feeding it malformed input as you add routes. A sanitizer finds memory errors, but it can’t tell you whether a user is allowed to read a record.

How does this compare with a web framework?

C is the language, CGI is how it talks to the web server, and a framework is a set of application tools and conventions. You can mix them: a C app can use a library like kcgi, and a Django app can call C code.

Compared with a full framework like Django or Rails, here’s what our C program looks like:

AreaOur C program with classic CGIA typical application using a full web framework
DeploymentInstall the binary and configure the web serverInstall the application and runtime, and configure its serving environment
Request lifetimeStart and initialize a process for each requestCommonly reuse application workers across requests
Forms and outputChoose libraries or implement parsing, validation, and escapingUse existing request, form, and template facilities
Product featuresBuild or integrate sessions, authentication, and migrationsMany common features already have framework support
DependenciesA small program can keep its dependency set narrowThe framework and selected packages need maintenance
CollaborationThe team needs to understand the C code and its conventionsShared framework conventions help developers find their way around

What you get with CGI in C is a tiny deployment. Our example is one executable plus the Apache config, with no interpreter (a dynamically linked build still needs the right runtime libraries on the host). There’s no app daemon to supervise, and replacing the binary updates the next request without restarting anything. You can reproduce any request with env and a pipe, as we did above. And memory gets reclaimed when each process exits, so a leak can’t pile up across thousands of requests (separate processes aren’t a security sandbox, though). If you already have a C library that does an expensive calculation, CGI is the shortest path to putting a form in front of it.

What a framework saves you is all the code around the feature. Look at our program: almost all of it handles input and output, and the greeting itself is a few lines. Now add accounts, password resets, file uploads and an admin. Django provides routing, templates, an ORM, database migrations, and an admin interface, plus template escaping, CSRF protection, and parameterized queries. In C you write or integrate each of those yourself, and every buffer length or pointer mistake can become a memory safety bug. Another developer who knows Django also knows where to find the routes and models, while in a custom C app you have to document that yourself.

Performance depends on the workload. C makes CPU-heavy work fast, but classic CGI pays for process startup and initialization on every request, while a framework app keeps workers and database connections alive. If a request mostly waits on a database or an external API, the language barely matters. Many concurrent requests also mean many CGI processes. Measure real requests (startup, database access, concurrent clients) before you decide.

CGI, FastCGI, and where the program runs

With classic CGI, the executable starts over for each request: it initializes its libraries and reopens whatever it needs. For a small app that’s fine. For a busy one, startup and repeated database setup add up, and slow requests keep processes busy.

FastCGI keeps application processes alive across many requests. Parameters, input and output travel as protocol records over a connection, defined in the FastCGI specification. A CGI executable doesn’t turn into a FastCGI service by flipping a server setting: you need an adapter or a FastCGI library, and you need to reset request state between requests.

Nginx has FastCGI upstream support, but it can’t run a classic CGI executable directly like our Apache setup does. OpenBSD’s httpd is a different server from Apache’s httpd, with its own setup, so this config won’t carry over.

To deploy publicly, you need a host where you run and configure the server, like a VPS. A static host can’t run this program. Put HTTPS in front, set request limits and timeouts, and send diagnostics to a managed log.

To update the program, build a new file and atomically replace the old path on the same filesystem, instead of overwriting the running binary. And build for the target OS and CPU, because the macOS executable from this tutorial won’t run on a Linux server.

I would use CGI in C for a small web interface around an existing C program, especially on a server that already supports CGI, or when learning the HTTP boundary is the point. With frequent requests and expensive startup, I would compare FastCGI or a persistent server. A growing product with accounts, uploads and lots of screens is where I would start with a framework and call the C code from it where needed, and WebSockets need a server built around long-lived connections.

When the program fails through the server

If it works in the terminal and fails through Apache, check the error log:

tail -n 50 /tmp/cgi-c-tutorial/error.log

A 403 usually means directory access rules or permissions. The server’s user has to be able to traverse the parent directories and execute the binary. Don’t fix it by making the executable directory world-writable.

A 500 mentioning script headers usually means the program crashed, printed something on stdout too early, or didn’t write complete headers and the blank line. Missing runtime libraries can also stop a binary from starting.

If the server sends you the file instead of running it, fix the CGI mapping, and never put source files or secrets in a directory the server exposes for download.

A request that hangs usually means the program is reading past the body length, or the server timeout is too generous.

The server also runs your program with a different working directory and environment from your terminal, so use absolute paths for app files and check the server user’s permissions.

One historical feature can surprise you while debugging. CGI/1.1 allows command-line arguments for some GET and HEAD queries that contain no unencoded =. These “indexed queries” split words on +. Our program reads QUERY_STRING and uses int main(void), so it ignores that mechanism.

Once the greeting works through Apache, try adding /health and /greeting routes with PATH_INFO. Validate the request before sending headers, and escape each value for the format you put it in.

Want me to talk about your product? You can sponsor this site.

Related posts about clang: