Security
Zigmund provides a type-safe security system for protecting endpoints with API keys, bearer tokens, OAuth2 password flows, and JWT validation. Security providers are declared as handler parameters using the zigmund.Security() marker type.
Overview
Security in Zigmund follows the same pattern as dependency injection: you write a provider function that extracts and validates credentials from the request, then declare it in your handler's parameter list with zigmund.Security(). Zigmund resolves the provider before the handler runs. If the provider returns null, Zigmund responds with 401 Unauthorized automatically.
Security schemes are also registered with the application so they appear in the generated OpenAPI documentation, giving API consumers clear instructions on how to authenticate.
Security First Steps
The simplest security setup is a provider function that extracts a token from the request. The zigmund.Security() marker tells Zigmund to treat the parameter as a security dependency.
Example
const std = @import("std");
const zigmund = @import("zigmund");
fn tokenProvider(req: *zigmund.Request, allocator: std.mem.Allocator) !?[]const u8 {
_ = allocator;
const bearer = zigmund.HTTPBearer{};
const auth = (try bearer.resolve(req)) orelse return null;
return auth.credentials;
}
fn readCurrentToken(
token: zigmund.Security(tokenProvider, &.{}),
allocator: std.mem.Allocator,
) !zigmund.Response {
return zigmund.Response.json(allocator, .{
.token = token.value.?,
.authenticated = true,
});
}
Register the security scheme and route:
try app.addSecurityScheme("bearerAuth", .{
.http = .{
.scheme = "bearer",
.bearer_format = "JWT",
},
});
try app.get("/me", readCurrentToken, .{});
How It Works
tokenProvideruseszigmund.HTTPBearer{}to extract theAuthorization: Bearer <token>header.- If no token is present, the provider returns
null, and Zigmund responds with 401. - The handler receives the resolved token through
token.value.?. addSecuritySchemeregisters the scheme in the OpenAPI spec so Swagger UI shows the "Authorize" button.
Key Points
- The provider function must return an optional type (
!?T). Returningnulltriggers a 401 response. - The second argument to
Security()is a tuple of scope strings (&.{}). Use&.{}when no scopes are required. - Always register a matching security scheme with
addSecuritySchemeso the OpenAPI documentation is accurate.
API Key Authentication
API keys can be sent in headers, query parameters, or cookies. Zigmund provides built-in helpers for each location.
Header API Key
const std = @import("std");
const zigmund = @import("zigmund");
fn apiKeyProvider(req: *zigmund.Request, allocator: std.mem.Allocator) !?[]const u8 {
_ = allocator;
const scheme = zigmund.APIKeyHeader{ .name = "x-api-key" };
return scheme.resolve(req);
}
fn readApiKey(
api_key: zigmund.Security(apiKeyProvider, &.{}),
allocator: std.mem.Allocator,
) !zigmund.Response {
return zigmund.Response.json(allocator, .{
.api_key = api_key.value.?,
.authenticated = true,
});
}
Register the scheme:
try app.addSecurityScheme("apiKeyAuth", .{
.api_key = .{
.name = "x-api-key",
.in = .header,
},
});
try app.get("/api-key", readApiKey, .{});
How It Works
zigmund.APIKeyHeader{ .name = "x-api-key" }creates a resolver that looks for thex-api-keyrequest header.scheme.resolve(req)returns the header value ornullif missing.- The security scheme registration tells OpenAPI that clients must send the
x-api-keyheader.
Key Points
- Use
zigmund.APIKeyHeaderfor header-based keys,zigmund.APIKeyQueryfor query parameter keys, andzigmund.APIKeyCookiefor cookie-based keys. - The
.infield in the security scheme registration (.header,.query,.cookie) must match the resolver you use. - API key validation logic (checking against a database, for instance) goes inside the provider function after extracting the key.
HTTP Bearer
Bearer token authentication uses the standard Authorization: Bearer <token> header. Zigmund's HTTPBearer helper extracts and parses this header.
Example
const std = @import("std");
const zigmund = @import("zigmund");
fn bearerProvider(req: *zigmund.Request, allocator: std.mem.Allocator) !?[]const u8 {
_ = allocator;
const bearer = zigmund.HTTPBearer{};
const auth = (try bearer.resolve(req)) orelse return null;
return auth.credentials;
}
fn readBearerToken(
token: zigmund.Security(bearerProvider, &.{}),
allocator: std.mem.Allocator,
) !zigmund.Response {
return zigmund.Response.json(allocator, .{
.token = token.value.?,
.authenticated = true,
.scheme = "bearer",
});
}
Register the scheme:
try app.addSecurityScheme("bearerAuth", .{
.http = .{
.scheme = "bearer",
.bearer_format = "opaque",
},
});
How It Works
zigmund.HTTPBearer{}creates a resolver for theAuthorizationheader.bearer.resolve(req)parses the header and returns a struct with a.credentialsfield containing the raw token string.- If the header is missing or does not start with
Bearer, the resolver returnsnull.
Key Points
- The
.bearer_formatfield is informational for OpenAPI documentation. Common values are"JWT","opaque", or any string describing your token format. - The
authstruct returned byresolvecontains the.credentialsfield. You can use this to look up users, validate signatures, or perform any custom logic.
Get Current User
A common pattern is to resolve a bearer token into a full user object. The provider looks up the token in a user store and returns the matching user record.
Example
const std = @import("std");
const zigmund = @import("zigmund");
const User = struct {
username: []const u8,
email: []const u8,
full_name: []const u8,
disabled: bool,
};
const fake_users = [_]struct { token: []const u8, user: User }{
.{
.token = "alice-secret-token",
.user = .{
.username = "alice",
.email = "alice@example.com",
.full_name = "Alice Wonderson",
.disabled = false,
},
},
.{
.token = "bob-secret-token",
.user = .{
.username = "bob",
.email = "bob@example.com",
.full_name = "Bob Builder",
.disabled = true,
},
},
};
fn getCurrentUser(req: *zigmund.Request, allocator: std.mem.Allocator) !?User {
_ = allocator;
const bearer = zigmund.HTTPBearer{};
const auth = (try bearer.resolve(req)) orelse return null;
for (&fake_users) |*entry| {
if (std.mem.eql(u8, auth.credentials, entry.token)) {
return entry.user;
}
}
return null;
}
fn readCurrentUser(
current_user: zigmund.Security(getCurrentUser, &.{}),
allocator: std.mem.Allocator,
) !zigmund.Response {
const user = current_user.value.?;
return zigmund.Response.json(allocator, .{
.username = user.username,
.email = user.email,
.full_name = user.full_name,
.disabled = user.disabled,
});
}
fn readOwnItems(
current_user: zigmund.Security(getCurrentUser, &.{}),
allocator: std.mem.Allocator,
) !zigmund.Response {
const user = current_user.value.?;
return zigmund.Response.json(allocator, .{
.owner = user.username,
.items = &[_]struct { item_id: []const u8, title: []const u8 }{
.{ .item_id = "item-1", .title = "My first item" },
.{ .item_id = "item-2", .title = "My second item" },
},
});
}
How It Works
getCurrentUserextracts the bearer token, then searches the user database for a matching record.- If the token is missing or unrecognized, the provider returns
null, triggering a 401 response. - Both
readCurrentUserandreadOwnItemsshare the same provider. The resolvedUserstruct is available through.value.?.
Key Points
- The provider can return any type, not just strings. Returning a full
Userstruct means handlers never need to look up the user themselves. - Multiple handlers can share the same security provider, ensuring consistent authentication logic across your API.
- In production, replace the fake user array with a real database lookup.
Simple OAuth2
OAuth2 password flow lets clients exchange a username and password for an access token. Zigmund provides OAuth2PasswordBearer to extract the token and OAuth2PasswordRequestForm to parse the login form.
Example
const std = @import("std");
const zigmund = @import("zigmund");
const User = struct {
username: []const u8,
email: []const u8,
full_name: []const u8,
disabled: bool,
hashed_password: []const u8,
};
const fake_users = [_]User{
.{
.username = "johndoe",
.email = "johndoe@example.com",
.full_name = "John Doe",
.disabled = false,
.hashed_password = "fakehashedsecret",
},
.{
.username = "alice",
.email = "alice@example.com",
.full_name = "Alice Wonderson",
.disabled = true,
.hashed_password = "fakehashedsecret2",
},
};
fn findUser(username: []const u8) ?User {
for (&fake_users) |*u| {
if (std.mem.eql(u8, u.username, username)) return u.*;
}
return null;
}
fn verifyPassword(plain: []const u8, hashed: []const u8) bool {
const prefix = "fakehashed";
if (!std.mem.startsWith(u8, hashed, prefix)) return false;
return std.mem.eql(u8, hashed[prefix.len..], plain);
}
fn getCurrentUser(req: *zigmund.Request, allocator: std.mem.Allocator) !?User {
_ = allocator;
const oauth2 = zigmund.OAuth2PasswordBearer{ .token_url = "/token" };
const token = (try oauth2.resolve(req)) orelse return null;
return findUser(token);
}
fn loginForAccessToken(
req: *zigmund.Request,
allocator: std.mem.Allocator,
) !zigmund.Response {
const form = try zigmund.OAuth2PasswordRequestForm.fromRequest(req);
const user = findUser(form.username) orelse {
return zigmund.Response.json(allocator, .{
.@"error" = "invalid_credentials",
.detail = "Incorrect username or password",
});
};
if (!verifyPassword(form.password, user.hashed_password)) {
return zigmund.Response.json(allocator, .{
.@"error" = "invalid_credentials",
.detail = "Incorrect username or password",
});
}
return zigmund.Response.json(allocator, .{
.access_token = user.username,
.token_type = "bearer",
});
}
fn readCurrentUser(
current_user: zigmund.Security(getCurrentUser, &.{}),
allocator: std.mem.Allocator,
) !zigmund.Response {
const user = current_user.value.?;
if (user.disabled) {
return zigmund.Response.json(allocator, .{
.@"error" = "inactive_user",
.detail = "User account is disabled",
});
}
return zigmund.Response.json(allocator, .{
.username = user.username,
.email = user.email,
.full_name = user.full_name,
.disabled = user.disabled,
});
}
Register the security scheme and routes:
try app.addSecurityScheme("oauth2PasswordBearer", .{
.oauth2 = .{
.flows = .{
.password = .{
.token_url = "/token",
},
},
},
});
try app.post("/token", loginForAccessToken, .{});
try app.get("/me", readCurrentUser, .{});
How It Works
- The client sends a POST to
/tokenwithusernameandpasswordas form fields. loginForAccessTokenparses the form usingOAuth2PasswordRequestForm.fromRequest(), validates credentials, and returns an access token.- On subsequent requests, the client includes
Authorization: Bearer <token>. getCurrentUserusesOAuth2PasswordBearerto extract the token, then looks up the user.- Protected endpoints declare
zigmund.Security(getCurrentUser, &.{})and receive the resolved user.
Key Points
OAuth2PasswordBeareris configured with atoken_urlthat tells Swagger UI where to send login requests.OAuth2PasswordRequestForm.fromRequest()parses the standard OAuth2 form fields:username,password,scope,client_id, andclient_secret.- In this simplified example, the token is the username itself. In production, issue a signed JWT or opaque token.
- Check the
disabledfield (or any other user attribute) inside the handler to enforce account-level restrictions.
OAuth2 with JWT
For production systems, combine OAuth2 password flow with JSON Web Tokens. Zigmund's VerifiedHS256Bearer validates JWT signatures, expiry, and issuer claims automatically.
Example
const std = @import("std");
const zigmund = @import("zigmund");
const JWT_SECRET = "zigmund-demo-secret-key-change-me";
fn jwtProvider(req: *zigmund.Request, allocator: std.mem.Allocator) !?[]const u8 {
_ = allocator;
const verifier = zigmund.VerifiedHS256Bearer{
.secret = JWT_SECRET,
.validation = .{
.issuer = "zigmund-demo",
.clock_skew_seconds = 120,
},
};
return verifier.resolve(req);
}
fn readProtected(
jwt: zigmund.Security(jwtProvider, &.{}),
allocator: std.mem.Allocator,
) !zigmund.Response {
const token = jwt.value.?;
// Parse JWT payload to extract claims...
return zigmund.Response.json(allocator, .{
.authenticated = true,
.token_length = token.len,
});
}
fn issueToken(
req: *zigmund.Request,
allocator: std.mem.Allocator,
) !zigmund.Response {
const form = try zigmund.OAuth2PasswordRequestForm.fromRequest(req);
const iat = std.time.timestamp();
const exp = iat + 3600; // 1-hour expiry
const payload_json = try std.fmt.allocPrint(
allocator,
"{{\"sub\":\"{s}\",\"iss\":\"zigmund-demo\",\"iat\":{d},\"exp\":{d}}}",
.{ form.username, iat, exp },
);
defer allocator.free(payload_json);
const header_json = "{\"alg\":\"HS256\",\"typ\":\"JWT\"}";
const token = try makeHs256Jwt(allocator, header_json, payload_json, JWT_SECRET);
defer allocator.free(token);
return zigmund.Response.json(allocator, .{
.access_token = token,
.token_type = "bearer",
});
}
Register the security scheme and routes:
try app.addSecurityScheme("oauth2JwtBearer", .{
.oauth2 = .{
.flows = .{
.password = .{
.token_url = "/token",
},
},
},
});
try app.post("/token", issueToken, .{});
try app.get("/protected", readProtected, .{});
How It Works
- The
/tokenendpoint constructs a JWT with standard claims (sub,iss,iat,exp), signs it with HS256, and returns it. jwtProviderusesVerifiedHS256Bearerto extract the bearer token, verify the HMAC-SHA256 signature, check the expiry, and validate the issuer claim.- If any validation fails, the provider returns
nulland the client receives a 401 response. - The handler receives the full, verified JWT string and can parse the payload segment to access individual claims.
Key Points
VerifiedHS256Bearerperforms signature verification, expiry checking, and issuer validation in a single step.- The
.clock_skew_secondsfield allows a tolerance window for clock differences between servers. - In production, load
JWT_SECRETfrom an environment variable, not a hardcoded string. - For RS256 or other algorithms, implement a custom provider using standard Zig crypto libraries.
- The token payload can contain any JSON claims. Parse the base64url-encoded payload segment to extract
sub, custom scopes, or role information.
See Also
- Dependencies -- The general dependency injection system that security builds on.
- Middleware -- Request/response hooks for cross-cutting concerns like logging.
- Handling Errors -- Customizing error responses for authentication failures.