Skip to content

JWT Authentication

The jwtAuth middleware verifies JSON Web Tokens (JWT) signed with HMAC-SHA256. It extracts the token from the Authorization: Bearer header, validates the signature using a shared secret, and stores the decoded payload JSON in assigns.

OptionTypeDefaultDescription
secret[]const u8(required)The HMAC-SHA256 secret key used to verify token signatures
assign_key[]const u8"jwt_payload"Key used to store the decoded payload JSON in ctx.assigns
requiredboolfalseWhen true, returns 401 if the token is missing or invalid
  1. Apply the jwtAuth middleware to the routes you want to protect, providing your secret key.

  2. In your handler, read the decoded JWT payload from assigns.

  3. Parse the payload JSON to extract claims as needed.

const zzz = @import("zzz");
const routes = zzz.Router.scope("/api", &.{
zzz.jwtAuth(.{ .secret = "my-hmac-secret", .required = true }),
}, &.{
zzz.Router.get("/me", meHandler),
});
fn meHandler(ctx: *zzz.Context) !void {
const payload = ctx.getAssign("jwt_payload").?;
// payload is the raw JSON string, e.g. {"sub":"1234","name":"Alice"}
var buf: [512]u8 = undefined;
const body = std.fmt.bufPrint(&buf,
\\{{"auth":"jwt","payload":{s}}}
, .{payload}) catch return;
ctx.json(.ok, body);
}

Test with curl:

Terminal window
curl -H "Authorization: Bearer <your-jwt-token>" http://127.0.0.1:9000/api/me

The middleware performs the following steps internally:

  1. Extract the bearer token from the Authorization header.

  2. Split the token into three base64url-encoded parts: header.payload.signature.

  3. Decode the signature and compute HMAC-SHA256 over the header.payload string using the configured secret.

  4. Compare the computed MAC with the decoded signature using constant-time comparison to prevent timing attacks.

  5. If the signature is valid, base64url-decode the payload and store the resulting JSON string in assigns.

The middleware supports both standard base64 and base64url encoding, with or without padding, so tokens generated by most JWT libraries will work.

zzz expects standard JWT format with three dot-separated segments:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0IiwibmFtZSI6IkFsaWNlIn0.<signature>
  • Header: must specify "alg":"HS256". Other algorithms are not supported.
  • Payload: any valid JSON object. The entire decoded payload is stored as a string in assigns.
  • Signature: HMAC-SHA256 of header.payload, base64url-encoded.

The JWT payload is stored as a raw JSON string. To work with individual claims, you can parse it using Zig’s standard JSON parser or do simple string matching:

fn handler(ctx: *zzz.Context) !void {
const payload = ctx.getAssign("jwt_payload") orelse {
ctx.text(.unauthorized, "No token");
return;
};
// Simple check: does the payload contain a specific claim value?
if (std.mem.indexOf(u8, payload, "\"role\":\"admin\"") != null) {
ctx.json(.ok, "{\"access\":\"admin\"}");
} else {
ctx.respond(.forbidden, "text/plain; charset=utf-8", "403 Forbidden");
}
}

zzz does not include a JWT generation function — it is a verification-only middleware. You can generate tokens in your login handler using the standard library’s HMAC implementation:

const std = @import("std");
const zzz = @import("zzz");
fn loginHandler(ctx: *zzz.Context) !void {
// After validating user credentials...
const header_b64 = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9";
// Build payload JSON
var payload_buf: [256]u8 = undefined;
const payload_json = std.fmt.bufPrint(&payload_buf,
\\{{"sub":"user-123","name":"Alice"}}
, .{}) catch return;
// Base64url-encode the payload
const encoder = std.base64.url_safe_no_pad;
var payload_b64_buf: [512]u8 = undefined;
const payload_b64 = encoder.encode(&payload_b64_buf, payload_json);
// Compute HMAC-SHA256 signature
var signed_buf: [1024]u8 = undefined;
const signed_part = std.fmt.bufPrint(&signed_buf, "{s}.{s}", .{
header_b64, payload_b64,
}) catch return;
const HmacSha256 = std.crypto.auth.hmac.sha2.HmacSha256;
var mac: [HmacSha256.mac_length]u8 = undefined;
HmacSha256.create(&mac, signed_part, "my-hmac-secret");
// Base64url-encode the signature
var sig_b64_buf: [64]u8 = undefined;
const sig_b64 = encoder.encode(&sig_b64_buf, &mac);
// Assemble the token
var token_buf: [2048]u8 = undefined;
const token = std.fmt.bufPrint(&token_buf,
\\{{"token":"{s}.{s}"}}
, .{ signed_part, sig_b64 }) catch return;
ctx.json(.ok, token);
}

The secret field is a comptime value. For production applications, you can load it from a build-time configuration:

const app_config = @import("app_config");
const routes = zzz.Router.scope("/api", &.{
zzz.jwtAuth(.{
.secret = app_config.jwt_secret,
.required = true,
}),
}, &.{
zzz.Router.get("/protected", protectedHandler),
});

Since the secret is embedded at compile time, make sure your build configuration keeps it out of version control. Use Zig build options or environment-based config files to inject it.

When required = true and authentication fails, the middleware responds with:

HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer
Content-Type: text/plain; charset=utf-8
401 Unauthorized

This happens when:

  • The Authorization header is missing
  • The header does not start with Bearer
  • The token does not have three dot-separated segments
  • The HMAC-SHA256 signature does not match
  • The signature or payload cannot be base64-decoded
  • HMAC-SHA256 only — the middleware exclusively supports the HS256 algorithm. RSA and ECDSA signatures are not supported.
  • Constant-time comparison — signature verification uses constant-time byte comparison to prevent timing side-channel attacks.
  • No expiration checking — the middleware does not validate exp, nbf, or iat claims. If you need expiration enforcement, check the claims in your handler after parsing the payload.
  • Payload size limit — the decoded payload buffer is 2048 bytes. Tokens with larger payloads will fail verification.