Constructing a Minimalist, Secure SNS Event Processor with Zig, Docker, and OIDC JWT Validation


The operational cost of our event-processing infrastructure was becoming a line item worthy of discussion. We were running a fleet of containerized services on AWS Fargate, each responsible for ingesting webhooks from AWS SNS. The services themselves were simple: parse a JSON payload, perform a quick transformation, and forward the data. The initial implementations in Go and Python were functional but exhibited a memory footprint of 30-60 MB per instance and non-trivial cold start times. In an environment that scales on demand, this baseline resource consumption, multiplied by hundreds of instances during traffic spikes, translated directly to unnecessary expense. The technical challenge was to build a replacement with a memory footprint under 5 MB and near-instantaneous startup, without sacrificing security or observability.

Our evaluation of alternatives led us down an unconventional path. Rust was a strong contender, but its learning curve and compiler strictness felt like overkill for this specific task. Go, even with optimizations, couldn’t reliably get us into the single-digit megabyte range for a service with HTTP and JSON parsing capabilities. This led us to Zig. Its core promises—simplicity, C ABI compatibility, and explicit memory management—offered precise control over the final binary’s size and runtime behavior. The decision was made to prototype an SNS processor in Zig, containerize it using a minimal scratch image, and secure its operational endpoints using OpenID Connect (OIDC), our organization’s standard for service-to-service authentication.

The architecture is straightforward. An internet-facing Application Load Balancer (ALB) forwards traffic to our Fargate service. The service exposes two endpoints: a public /webhook path for AWS SNS notifications and a private /metrics path for our Prometheus scraper. All SNS traffic is unsigned and public, but the /metrics endpoint must be protected, requiring a valid JWT bearer token issued by our internal OIDC provider.

graph TD
    subgraph "AWS"
        SNS[AWS SNS Topic] -- HTTPS POST --> ALB[Application Load Balancer]
    end

    subgraph "AWS Fargate Task"
        ALB -- /webhook --> ZigService[Zig SNS Processor]
    end

    subgraph "Internal Network"
        Prometheus[Prometheus Scraper] -- GET /metrics with JWT --> ALB
        ALB -- /metrics --> ZigService
        ZigService -- Validates JWT --> OIDC[OIDC Provider/.well-known/jwks.json]
    end

    style ZigService fill:#f9f,stroke:#333,stroke-width:2px

The initial step was to create a basic HTTP server in Zig capable of handling concurrent requests. Instead of pulling in a large framework, we opted for Zig’s standard library std.net combined with its new async capabilities. This provides the necessary building blocks without unnecessary abstraction.

Here is the foundational server structure. It sets up a listening socket and enters a loop, accepting new connections and spawning a new async frame to handle each one. A common mistake in low-level servers is blocking the main accept loop while processing a request. Using async and spawn ensures the server remains responsive.

const std = @import("std");
const Allocator = std.mem.Allocator;
const Request = std.http.Server.Request;
const Response = std.http.Server.Response;

// Arena allocator for request-scoped memory management.
// This is crucial for preventing memory leaks in a long-running service.
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
const allocator = gpa.allocator();

pub fn main() !void {
    var server = std.http.Server.init(allocator, .{
        .reuse_address = true,
    });
    defer server.deinit();

    // Configuration from environment variables is a cloud-native best practice.
    const port_str = std.process.getEnvVarOwned(allocator, "PORT") catch "8080";
    defer allocator.free(port_str);
    const port = std.fmt.parseInt(u16, port_str, 10) catch 8080;

    const address = try std.net.Address.parseIp("0.0.0.0", port);
    try server.listen(address);
    std.log.info("Server listening on {s}:{d}", .{ address.getIpString(), port });

    while (true) {
        var response = try server.accept();
        // Spawn an async frame to handle the request concurrently.
        // This is key to handling multiple connections without blocking.
        _ = try std.Thread.spawn(.{}, handleRequest, .{response});
    }
}

fn handleRequest(response: Response) void {
    // Each request gets its own Arena allocator. All memory allocated
    // here will be freed automatically when the function returns.
    var arena = std.heap.ArenaAllocator.init(allocator);
    defer arena.deinit();
    const req_allocator = arena.allocator();

    // We must ensure that the request is fully processed.
    response.wait() catch |err| {
        std.log.err("Failed to wait for request: {}", .{err});
        return;
    };

    const request = response.request;
    const path = request.target;
    const method = request.method;

    std.log.info("Request: {s} {s}", .{ @tagName(method), path });

    // Simple routing logic.
    if (std.mem.eql(u8, path, "/webhook") and method == .POST) {
        processSnsWebhook(req_allocator, response) catch |err| {
            std.log.err("Error processing SNS webhook: {}", .{err});
            respondWithError(response, 500, "Internal Server Error");
        };
    } else if (std.mem.eql(u8, path, "/metrics") and method == .GET) {
        // Placeholder for OIDC protected endpoint
        respond(response, 200, "text/plain", "OK");
    } else if (std.mem.eql(u8, path, "/health") and method == .GET) {
        respond(response, 200, "text/plain", "OK");
    } 
    else {
        respondWithError(response, 404, "Not Found");
    }
}

fn respond(response: Response, status: u16, content_type: []const u8, body: []const u8) void {
    response.status = status;
    response.headers.append("Content-Type", content_type) catch {};
    response.headers.append("Content-Length", &std.fmt.allocPrint(allocator, "{}", .{body.len}) catch unreachable) catch {};
    response.transfer_encoding = .content_length;
    
    response.do() catch |err| {
        std.log.err("Failed to send response headers: {}", .{err});
        return;
    };
    
    response.writer().writeAll(body) catch |err| {
        std.log.err("Failed to write response body: {}", .{err});
    };
}

fn respondWithError(response: Response, status: u16, message: []const u8) void {
    const body = std.fmt.allocPrint(allocator, "{{\"error\":\"{s}\"}}", .{message}) catch unreachable;
    defer allocator.free(body);
    respond(response, status, "application/json", body);
}

With the server foundation in place, the next task was parsing SNS messages. SNS sends a JSON payload with a Type field: SubscriptionConfirmation or Notification. For the former, we must visit the SubscribeURL to confirm the subscription. For the latter, we process the Message. A common pitfall here is to not handle the subscription confirmation correctly, leading to a dead subscription.

We defined Zig structs to represent the incoming JSON structure, using std.json.parseFromSlice for deserialization. This avoids manual parsing and is much safer.

// In the same file as main, adding SNS specific logic.

const SnsMessageHeader = struct {
    Type: []const u8,
    MessageId: []const u8,
    TopicArn: []const u8,
};

const SnsSubscriptionConfirmation = struct {
    Type: []const u8,
    MessageId: []const u8,
    Token: []const u8,
    TopicArn: []const u8,
    SubscribeURL: []const u8,
};

fn processSnsWebhook(alloc: Allocator, response: Response) !void {
    const MAX_BODY_SIZE = 1 * 1024 * 1024; // 1MB limit
    const body = try response.request.readBodyAlloc(alloc, MAX_BODY_SIZE);
    defer alloc.free(body);

    var json_stream = std.json.StreamingParser.init(body, .{ .allocator = alloc });
    const parsed_header = try std.json.parseFromStream(SnsMessageHeader, &json_stream, .{});
    defer std.json.parseFree(SnsMessageHeader, parsed_header, .{ .allocator = alloc });

    if (std.mem.eql(u8, parsed_header.Type, "SubscriptionConfirmation")) {
        // We need to re-parse the full object for the SubscribeURL.
        json_stream.reset(); // Reset parser to read from the beginning
        const parsed_sub = try std.json.parseFromStream(SnsSubscriptionConfirmation, &json_stream, .{});
        defer std.json.parseFree(SnsSubscriptionConfirmation, parsed_sub, .{ .allocator = alloc });
        
        std.log.info("Received SNS SubscriptionConfirmation for topic {s}", .{parsed_sub.TopicArn});
        try confirmSnsSubscription(alloc, parsed_sub.SubscribeURL);
        respond(response, 200, "text/plain", "Subscription confirmed");
    } else if (std.mem.eql(u8, parsed_header.Type, "Notification")) {
        std.log.info("Received SNS Notification for topic {s}", .{parsed_header.TopicArn});
        // In a real application, you would parse the full Notification object
        // and process the `Message` field here.
        // For this example, we just acknowledge receipt.
        respond(response, 200, "text/plain", "Notification received");
    } else {
        std.log.warn("Received unknown SNS message type: {s}", .{parsed_header.Type});
        respondWithError(response, 400, "Unknown message type");
    }
}

fn confirmSnsSubscription(alloc: Allocator, url_str: []const u8) !void {
    std.log.info("Confirming subscription at URL: {s}", .{url_str});
    
    // In a production system, use a proper HTTP client. For simplicity,
    // this shows the core logic. Zig's standard library does not have a
    // built-in HTTP client yet, so one might use a library or shell out.
    // Let's simulate for now, as adding a full client is out of scope.
    // Example using `zig-http-client`:
    // var client = try std.http.Client.init(alloc);
    // defer client.deinit();
    // var req = try client.request(.GET, try std.Uri.parse(url_str), .{});
    // try req.do(); // etc.
    // For this demonstration, we assume confirmation is successful.
    _ = alloc;
    _ = url_str;
}

The next critical piece is containerization. The goal is an image that is as small as possible. A multi-stage Dockerfile is perfect for this. The builder stage uses the official Zig image to compile our application into a static, release-small binary. The final stage copies this single binary into a scratch image, which is completely empty. The resulting image contains only our application and nothing else, drastically reducing its size and attack surface.

# Stage 1: The Builder
# Use the official Zig compiler image
FROM ziglang/zig:0.11.0 as builder

# Set the working directory
WORKDIR /app

# Copy the source code
COPY . .

# Build the application as a static binary, optimized for size.
# The `-target` flag is crucial for creating a truly static binary
# that can run in a scratch container. We target musl for this.
RUN zig build -Doptimize=ReleaseSmall -Dtarget=x86_64-linux-musl

# Stage 2: The Final Image
# Use a scratch image for the smallest possible size
FROM scratch

# Copy the compiled binary from the builder stage
COPY --from=builder /app/zig-out/bin/sns-zig-processor /sns-zig-processor

# Expose the port the server will listen on
EXPOSE 8080

# Set the entrypoint for the container
ENTRYPOINT ["/sns-zig-processor"]

Securing the /metrics endpoint with OIDC is the most complex part. A full OIDC implementation is non-trivial. It involves fetching the provider’s discovery document, retrieving the JSON Web Key Set (JWKS), and then using the correct public key to verify the signature of a JWT. A pragmatic senior engineer does not reinvent cryptographic wheels. We leveraged Zig’s C interoperability to use a battle-tested C library, libjwt.

First, the build.zig file needs to be updated to compile and link libjwt.

// build.zig
const std = @import("std");

pub fn build(b: *std.Build) void {
    const target = b.standardTargetOptions(.{});
    const optimize = b.standardOptimizeOption(.{});

    const exe = b.addExecutable(.{
        .name = "sns-zig-processor",
        .root_source_file = b.path("src/main.zig"),
        .target = target,
        .optimize = optimize,
    });

    // Add libjwt as a dependency. In a real project, this might
    // be a git submodule or a system package. Here we assume it's
    // located in a `lib/libjwt` directory.
    // For this example, we will just link the library assuming it is installed on the system
    // for local development. The Dockerfile would need to handle its installation.
    exe.linkSystemLibrary("c");
    exe.linkSystemLibrary("jwt"); // Link against libjwt

    b.installArtifact(exe);

    const run_cmd = b.addRunArtifact(exe);
    run_cmd.step.dependOn(b.getInstallStep());
    if (b.args) |args| {
        run_cmd.addArgs(args);
    }
    const run_step = b.step("run", "Run the app");
    run_step.dependOn(&run_cmd.step);
}

Now, we can implement the OIDC validation logic in Zig. This involves creating a C binding file (c.zig) for the necessary libjwt functions and then writing a validation module. The flow is:

  1. On startup, fetch the OIDC configuration and then the JWKS, caching the keys in memory.
  2. For a request to /metrics, extract the Authorization: Bearer <token> header.
  3. Decode the JWT without verification to inspect its header and find the Key ID (kid).
  4. Find the matching public key from the cached JWKS.
  5. Verify the JWT’s signature using the public key.
  6. Validate standard claims (iss, aud, exp).

Here’s the conceptual implementation of the OIDC middleware. A production implementation requires a robust HTTP client and careful error handling.

```zig
// oidc.zig - conceptual module for OIDC validation
const std = @import(“std”);
const c = @import(“c.zig”); // Assumes a file with libjwt C bindings
const Allocator = std.mem.Allocator;

// This would hold parsed JWK keys. For simplicity, we’ll store them as raw strings.
var jwks_cache: ?[]const u8 = null;
var oidc_issuer: ?[]const u8 = null;
var oidc_audience: ?[]const u8 = null;

// This should be called once on application startup.
pub fn init(alloc: Allocator) !void {
oidc_issuer = std.process.getEnvVarOwned(alloc, “OIDC_ISSUER_URL”) catch |err| {
std.log.err(“Missing env var: OIDC_ISSUER_URL”, .{});
return err;
};
oidc_audience = std.process.getEnvVarOwned(alloc, “OIDC_AUDIENCE”) catch |err| {
std.log.err(“Missing env var: OIDC_AUDIENCE”, .{});
return err;
};

// 1. Fetch OIDC discovery document -> get jwks_uri
// 2. Fetch JWKS from jwks_uri
// 3. Store the JWKS JSON body in `jwks_cache`.
// This part requires an HTTP client and JSON parsing.
// For now, let's assume it's loaded.
jwks_cache = "{\"keys\":[...]}"; // Placeholder
std.log.info("OIDC initialized for issuer: {s}", .{oidc_issuer.?});

}

pub fn validateToken(alloc: Allocator, token_str: []const u8) !void {
var jwt: ?*c.jwt_t = null;
if (c.jwt_decode(&jwt, token_str.ptr, token_str.len, null, 0) != 0) {
return error.JwtDecodeFailed;
}
defer c.jwt_free(jwt);

// Get the Key ID (kid) from the token header
const kid = c.jwt_get_header(jwt, "kid");
if (kid == null) {
    return error.MissingKid;
}

// Find the corresponding key in our cached JWKS
const key_pem = try findKeyInJwks(alloc, @ptrCast([*c]const u8, kid));
defer alloc.free(key_pem);

// Create a jwt_key_t object from the PEM key.
var key: ?*c.jwt_key_t = null;
if (c.jwt_key_from_pem(key_pem.ptr, key_pem.len, &key) != 0) {
    return error.KeyParseFailed;
}
defer c.jwt_key_free(key);

// Verify the signature
if (c.jwt_verify(jwt, key) != 0) {
    return error.SignatureVerificationFailed;
}

// Validate claims
const exp = c.jwt_get_grant_int(jwt, "exp");
const current_time = std.time.timestamp();
if (exp <= current_time) {
    return error.TokenExpired;
}

const iss = c.jwt_get_grant(jwt, "iss");
if (!std.mem.eql(u8, oidc_issuer.?, @ptrCast([*c]const u8, iss))) {
    return error.InvalidIssuer;
}

// Audience validation is more complex as it can be a string or array.
// This is a simplified check.
const aud = c.jwt_get_grant(jwt, "aud");
if (!std.mem.eql(u8, oidc_audience.?, @ptrCast([*c]const u8, aud))) {
    return error.InvalidAudience;
}

std.log.info("JWT validation successful", .{});

}

fn findKeyInJwks(alloc: Allocator, kid: []const u8)


  TOC