Skip to content

Auth and request protection

Anole’s auth pieces are separate parts that you combine as middleware: JWT verification, OIDC sign-in, sessions, CSRF tokens, password hashes, API keys, roles and rate limits. You choose which routes get which. anole.auth needs -Dauth (on by default); anole.session and anole.ratelimit are always built, and their Redis stores need -Dredis (on in the default full profile). examples/auth/main.zig puts most of this guide together; see Try the example.

Middleware runs in the order you list it. A global app.use(...) must come before the first route. For protection per route, use groups:

const M = anole.http.Middleware(App);
const web = [_]M{ .init(&sessions), .init(&csrf) };
const login_mw = [_]M{ .init(&login_limit), .init(&sessions), .init(&csrf) };
app.group("", &web).get("/me", me);
app.group("", &login_mw).post("/login", login);

Keep the middleware values and the arrays alive for as long as the server runs. Locals in main before app.run() are fine. Put the rate limit first, so that requests the later middleware refuses still count against the limit.

anole.auth.verify(arena, token, keys, policy, now) Error!Verified checks a JWS compact token (a signed JWT) against your keys and a claims policy.

A Key has an optional kid, exactly one alg, and its material (.hmac, .rsa with big-endian n and e, .p256, .p384 or .ed25519). It supports HS256/384/512, RS256/384/512, PS256/384/512, ES256, ES384 and EdDSA, all on Zig’s std.crypto. Policy holds issuer, audience, typ, leeway (a Duration, 60 s), require_exp (true), require_iat (false), max_age (a Duration) and allow_any_audience (false). now is in Unix seconds, for example std.Io.Clock.real.now(req.io).toSeconds().

Each key verifies only its own algorithm. A token whose alg differs from the key’s never verifies. An RSA public key therefore can’t be used as an HMAC secret.

These defaults are enforced, not optional:

  • alg: none and unknown algorithms fail with error.UnsupportedAlgorithm, and so does any crit header.
  • exp is required (error.MissingExpiry). Turn off require_exp only for tokens that really have no expiry.
  • nbf and iat are checked when present (require_iat makes iat mandatory). iss, aud (a string or an array), the header’s typ and max_age are checked when you set them in the policy.
  • Set issuer and audience whenever the keys also sign tokens for others, as an identity provider’s do: without audience, a token the provider issued to another application verifies too. A Keyring refuses a policy without audience (error.AudienceRequired; RequireJwt answers 500 and logs the fix) unless you set allow_any_audience = true for keys that sign for this application alone.
  • typ = "at+jwt" accepts only RFC 9068 access tokens (case and an application/ prefix aside), so an ID token from the same provider is not taken for one; use it when your provider marks its access tokens.
  • An HMAC comparison runs in constant time.
  • An HMAC secret shorter than its hash (32 bytes for HS256, 48 for HS384, 64 for HS512, as RFC 7518 requires) verifies nothing: verify fails with error.WeakKey, and RequireJwt answers 500 and logs the fix.

The result, Verified, gives you the header and claims as JSON objects, plus string("sub") and integer("exp"). They live in the arena you pass, which is usually req.arena.

anole.auth.RequireJwt is a middleware that requires Authorization: Bearer <JWT>. It verifies the token with your keys or a Keyring, checks it against a Policy (the required issuer and audience go there), and refuses the request as RFC 6750 says:

Request Answer
No Authorization, or another scheme 401, WWW-Authenticate: Bearer
Bearer with no token, or more than one 400, error="invalid_request"
A token that does not verify, or misses a required claim 401, error="invalid_token" (expired ones also get an error_description)
A token without the required scopes 403, error="insufficient_scope"
The keyring could not fetch the keys 503, so the client keeps its token
The HMAC key is shorter than its hash (error.WeakKey), or a keyring policy has no audience (error.AudienceRequired) 500, and an error in the log
const api = app.group("/api", .{anole.auth.RequireJwt.init(.{ .keyring = &keyring }, .{ .issuer = "https://issuer.example", .audience = "api" }, .{
.scopes = &.{"notes:read"}, // all of these in the `scope` claim
.realm = "api",
})});
api.get("/me", me);
fn me(req: *anole.Request, res: *anole.Response) !void {
const token = anole.auth.claims(req) orelse return error.Unauthorized;
try res.print(.text, "{s} ({s})", .{ anole.auth.subject(req) orelse "-", token.string("role") orelse "-" });
}

Keys are .{ .static = &keys } or .{ .keyring = &keyring }. The handler gets the verified token from the request’s locals (Routing, requests and responses): anole.auth.claims(req) is the Verified (claims and header as JSON objects, string and integer to read one), anole.auth.subject(req) its sub. Both are null on a route without RequireJwt. For claims that are arrays or objects, or checks of your own, set .hook = .{ .ptr = ..., .func = ... }: it gets the whole Verified before the handler, and an error from it refuses the token.

.required_claims lists string claims a token must carry; one without them gets 401 invalid_token. It is &.{"sub"} by default, so anole.auth.subject(req) is never null behind the middleware; &.{} requires none. Nothing from the token goes into req.session.

To issue tokens, use anole.auth.jwt, the patched zig-jwt fork. This fork compares MACs in constant time and has no none or MD5/SHA-1 methods. Signing is deterministic for HS*, RS*, ES* and EdDSA. PS* needs withRandom first and panics without it.

var signer = anole.auth.jwt.SigningMethodHS256.init(arena);
const token = try signer.signWithHeader(.{ .typ = "JWT", .alg = "HS256", .kid = "k1" }, .{
.iss = "me", .aud = "api", .sub = "alice", .iat = now, .exp = now + 3600,
}, secret);

Use an HMAC secret of 32 random bytes or more, kept in your secret store. For tokens that other services verify, prefer ES256 or EdDSA, so that those services hold only a public key.

anole.auth.parseJwks(arena, json) turns a JWK Set into keys. It skips use: enc keys. A key without alg gets the one its type implies: RSA gets RS256, P-256 gets ES256, P-384 gets ES384 and Ed25519 gets EdDSA.

For a provider’s jwks_uri, use anole.auth.Keyring. Give it the server’s client, app.client() (or anole.client(io)), and the URL. It fetches the set on first use, fetches it again after ttl (1 hour), and fetches it once more when a token names an unknown kid. Providers rotate keys, but those extra fetches are at least min_refresh_seconds (60) apart, so bogus tokens can’t make you hammer the provider.

var keyring = anole.auth.Keyring.init(gpa, app.io(), app.client(), "https://issuer.example/jwks.json");
defer keyring.deinit();
const v = try keyring.verify(req.arena, token, .{ .issuer = "https://issuer.example", .audience = "api" });

A fetch runs outside the keyring’s lock, one at a time. Requests that need it, because there are no keys yet, the keys are past their TTL, or the token’s kid is new, wait for that one fetch. If it fails, they get error.JwksFetchFailed. Requests the current keys can verify don’t wait, and verification itself runs outside the lock.

When the provider can’t be reached, keys past their TTL stay in use for stale_seconds (a day), so an outage there doesn’t fail every request at once. A failed fetch isn’t retried for retry_seconds (5), doubling with each failure up to max_retry_seconds (300); meanwhile requests use the stale keys, or get error.JwksFetchFailed at once when there are none, and a warning is logged per failed fetch.

anole.auth.oidc is a relying party. It is never a provider. It runs the authorization-code flow with PKCE (S256), a random state and nonce, and the ID-token checks of OIDC Core 3.1.3.7: signature, iss, aud, exp, iat, azp and nonce. The logic is ported from liboauth2.

You need a Provider, a Keyring on its jwks_uri, and a RelyingParty:

const provider = anole.auth.oidc.Provider.google; // or: try Provider.discover(gpa, app.client(), issuer_url)
var keyring = anole.auth.Keyring.init(gpa, app.io(), app.client(), provider.jwks_uri);
const rp: anole.auth.oidc.RelyingParty = .{
.provider = &provider,
.client_id = google_client_id, // from your configuration, never from source
.client_secret = google_client_secret,
.redirect_uri = "https://app.example/auth/google/callback",
.keyring = &keyring,
};

Start the flow on one route. Keep what begin returns in the session until the callback comes back:

const start = try rp.begin(req.arena, req.io);
try req.session.set("oidc_state", start.state);
try req.session.set("oidc_nonce", start.nonce);
try req.session.set("oidc_verifier", start.code_verifier);
res.status = .see_other;
try res.header("Location", start.url);

Finish the flow on the callback route:

const saved: anole.auth.oidc.Start = .{
.url = "",
.state = req.session.get("oidc_state") orelse return refuse(res),
.nonce = req.session.get("oidc_nonce") orelse return refuse(res),
.code_verifier = req.session.get("oidc_verifier") orelse return refuse(res),
};
const callback: anole.auth.oidc.Callback = .{
.code = req.query.get("code") orelse return refuse(res),
.state = req.query.get("state") orelse return refuse(res),
};
const id = rp.finish(req.arena, req.io, anole.client(req), callback, saved) catch |err| {
std.log.info("sign-in refused: {t}", .{err}); // StateMismatch, NonceMismatch, TokenRequestFailed, ...
return refuse(res);
};
for ([_][]const u8{ "oidc_state", "oidc_nonce", "oidc_verifier" }) |k| req.session.remove(k);
// The stable account key is the pair (issuer, subject): look the account up
// by both, never by subject alone, nor by email.
const account = try accounts.findOrCreate(id.issuer, id.subject);
try req.session.set("user", account.id);
try anole.session.regenerate(req); // a new session id for the signed-in session

refuse is your own function that answers 400 with a body, such as res.fail(.bad_request, "sign-in failed"). Returning error.BadRequest from the handler gives the same status with the status line as the body.

Identity holds issuer, subject, email, email_verified, name, every claim, and the access and refresh tokens. Key accounts on issuer and subject together: a sub is unique only within its issuer, so with two providers, or one whose tenants share keys, sub alone can name two people. Trust email only when email_verified is true, and never link an existing account by email alone.

The checks go beyond the signature: iat must be present, azp must be your client_id whenever the token has one (and the token must have one when it names several audiences), and the token request doesn’t follow redirects, so the code and client secret never go anywhere but the token_endpoint. Provider.discover refuses an issuer or endpoint that isn’t https:// (error.InsecureEndpoint); plain http:// is accepted only on loopback, for a test provider on the same machine.

The callback is a top-level navigation from the provider. Its state check is its CSRF protection, so don’t put the CSRF middleware on that route.

Google. Create an OAuth client of type “Web application” in the Google Cloud console, and add your exact callback URL as an authorized redirect URI. Use Provider.google, or Provider.discover with https://accounts.google.com. The default scope openid email profile is right.

Apple. Create a Services ID; it is the client_id. Then create a Sign in with Apple key, which gives you a .p8 file, a key ID and your team ID. Apple’s client secret is a short-lived ES256 JWT, which Anole makes for each token request:

const rp: anole.auth.oidc.RelyingParty = .{
.provider = &anole.auth.oidc.Provider.apple,
.client_id = "com.example.web", // the Services ID
.apple_secret = .{ .team_id = team_id, .key_id = key_id, .private_key_pem = p8_pem },
.redirect_uri = "https://app.example/auth/apple/callback",
.scope = "openid email name",
.response_mode = "form_post", // Apple requires it when name or email is in scope
.keyring = &apple_keyring,
};

With form_post, the callback is a cross-site POST, so read code and state from req.formData(). Browsers don’t send a SameSite=Lax cookie on that request. Set .cookie_opts = .{ .same_site = .none } on the session middleware; the other attributes keep their defaults (Secure, HttpOnly, Path=/). Apple sends email_verified as a string; Anole reads it as a boolean. Apple sends the user’s name only once, in a user form field on the first sign-in, so Identity.name is null for Apple.

The Google and Apple presets are tested against a fake provider, but not yet against the live services.

anole.session.Middleware loads req.session before the handler and saves it afterwards. It sets the cookie when the session changed or has data, and expires the cookie when the handler cleared the session. In a handler, use req.session.set(k, v), get, remove and clear. A session holds at most 16 entries, with no NUL bytes in keys or values.

Pass .key for a sealed cookie, or .store for server-side sessions. init returns an error union: in cookie mode it refuses to start without a key (error.SessionKeyMissing, with the fix in the log), so a forgotten key never gives cookies anyone could forge or read. Config also has cookie_name (anole_session), ttl (24 h, renewed by each response that carries the session) and cookie_opts (Path=/, HttpOnly, Secure, SameSite=Lax; setting one field keeps the others’ defaults). Chrome, Firefox and curl keep Secure cookies on http://localhost; anywhere else, serve over HTTPS. In production, name the cookie __Host-anole_session (.cookie_name): the browser then keeps it to this exact host and path /, so a sibling subdomain can’t set or overwrite it. init refuses a __Host- name whose cookie_opts lack Secure or Path=/, or set a Domain (error.CookiePrefixMismatch).

There are three ways to keep sessions:

Sealed cookie (.key) MemoryStore RedisStore
Where the data lives In the cookie, encrypted with XChaCha20-Poly1305 In this process In Redis or Valkey
Cookie carries All the data (keep it small) A random 128-bit ID A random 128-bit ID
Several nodes Yes, with the same key on each No Yes
Server-side logout of every device No: a copied cookie stays valid until it expires Yes Yes
Survives a restart Yes, if the key does No Yes

A sealed cookie that was changed, renamed or has expired is no session. Make its key from 32 random bytes, keep it in your secret store, and give every node the same one. anole.session.keyFromBase64 reads it from configuration (openssl rand -base64 32 makes one); a missing key, or one of all zeros, is refused. For local development only, anole.session.devKey(io) makes a random key and logs a warning: sessions then end on every restart and differ between nodes.

const key = try anole.session.keyFromBase64(init.environ_map.get("SESSION_KEY") orelse return error.NoSessionKey);
app.use(try anole.session.Middleware.init(.{ .key = key }));
// local development: .{ .key = anole.session.devKey(app.io()) }
var mem = anole.session.MemoryStore.init(gpa, app.io()); // then .store = mem.store()
const redis = try app.redis(.{}); // REDIS_URL, else redis://127.0.0.1:6379 (docs/database.md)
var rs: anole.session.RedisStore = .{ .client = redis, .gpa = gpa }; // keys "anole:session:<id>" with the TTL
var sessions = try anole.session.Middleware.init(.{ .store = rs.store() });

MemoryStore sweeps expired sessions as new ones are written and keeps at most max_entries (100,000; MemoryStore.init(gpa, io, .{ .max_entries = n })): past it, the session written longest ago is dropped, with a warning in the log.

A store is a vtable of load, save, destroy, update and touch (anole.session.Store), so you can bring your own. After the handler, the middleware writes a session the request changed with update, and renews one it only read with touch; both must do nothing and return false when the session no longer exists (Redis SET ... XX and EXPIRE). That way a request still running when the user logged out, or when another request called regenerate, cannot bring the old session back. A store without update or touch gets load followed by save, which leaves a short window for that.

Call anole.session.regenerate(req) when someone signs in, or when their privileges change. Otherwise an attacker who planted a session ID in the victim’s browser before the sign-in would share the signed-in session (session fixation). With a store, the middleware destroys the old ID in the store when it saves the response, then saves the data under a fresh ID and sends the new cookie. The data itself carries over.

try req.session.set("user", user.name);
try anole.session.regenerate(req);

A sealed cookie has no ID. regenerate makes the middleware seal it afresh (a new nonce), which it does on every save anyway. A copy of the old cookie stays valid until it expires, but it holds only what the session held before the sign-in, and nobody can forge one with a user in it without the key.

regenerate also drops the session’s CSRF token, so a token handed out before the sign-in is refused after it; the next csrf.token(req) makes a new one.

regenerate uses one of the session’s 16 entries, under anole.session.regenerate_key, until the response is saved. The middleware takes it out before saving.

anole.auth.csrf uses synchronizer tokens kept in the session, so place it after the session middleware. csrf.token(req) returns the session’s token and makes one on first use. Each token is 256 random bits, base64url. The middleware refuses POST, PUT, PATCH and DELETE with 403 unless the token comes back. A script sends it in the X-CSRF-Token header; a form sends it in the _csrf field of an application/x-www-form-urlencoded body. The token is compared in constant time.

var csrf = anole.auth.csrf.Middleware.init(.{});
// in the form: <input type="hidden" name="_csrf" value="{token}">

JSON bodies are not checked by default (check_json = false). A browser can’t send application/json to another site without a CORS preflight, as long as your CORS policy doesn’t allow it. Set check_json = true if you allow cross-origin JSON with credentials. After a sign-in, call req.session.remove(anole.auth.csrf.session_key) so the next page gets a fresh token.

anole.auth.password stores Argon2id hashes as PHC strings, at OWASP’s baseline (m = 19 MiB, t = 2, p = 1).

var buf: [128]u8 = undefined;
const phc = try anole.auth.password.hash(gpa, io, plain, &buf); // "$argon2id$v=19$m=19456,t=2,p=1$..."
const ok = anole.auth.password.verify(gpa, req.io, stored, plain); // also takes bcrypt "$2b$..."
if (ok and anole.auth.password.needsRehash(stored)) { ... hash again and store ... }

Pass a thread-safe allocator such as init.gpa, not req.arena: each call allocates 19 MiB and frees it again. A hash takes about 150 ms in a Debug build and much less in ReleaseFast. With the server’s blocking pool (.blocking = .{} in the server options, see The server), hash and verify run on a pool thread while the request waits, and the executor goes on serving. Without it, they keep the request’s executor busy for that long. hashHere and verifyHere always run on the calling thread. A malformed stored hash counts as no match, and so does a request canceled while verify runs.

When the user name is unknown, verify against a dummy hash anyway, so the answer takes as long as a wrong password does.

anole.auth.apikey.generate(arena, io) returns { key, digest }. The key is ak_ plus 256 random bits in base64url, 46 characters. Show the key to its owner once. Store only the digest, a SHA-256: the key is random, so a slow hash adds nothing.

apikey.Middleware takes the key from the request (apikey.fromRequest), hashes it, and asks your lookup function for the owner. It answers 401 with WWW-Authenticate: Bearer when there is no owner. The handler reads the owner with anole.auth.apikey.owner(req) (from the request’s locals).

The key can come in two headers:

  • X-API-Key: <key>, with any value: the header means nothing else, so keys without the prefix (imported from another system) work there.
  • Authorization: Bearer ak_..., only with the ak_ prefix. JWTs use the same header (RequireJwt), and the prefix is what tells the two apart: a bearer token without it is not an API key.

When both are sent, the bearer form wins.

fn lookup(ptr: ?*anyopaque, arena: std.mem.Allocator, digest: anole.auth.apikey.Digest) anyerror!?[]const u8 {
const db: *Db = @ptrCast(@alignCast(ptr.?));
return db.ownerOfKeyDigest(arena, &digest); // null: no such key
}
var api_keys = anole.auth.apikey.Middleware.init(.{ .ptr = &db, .lookup = lookup });

Mutual TLS is a listener setting (https.client_auth, in docs/server.md), and it covers HTTP/3 as well. A handler or middleware reads the verified certificate with req.peerCertificate(), the leaf as DER or null, the same way over HTTP/1.1, HTTP/2 and HTTP/3. Map it to an owner as you would an API key, for example by the SHA-256 of the DER or by a subject name from std.crypto.Certificate.

A Policy lists roles. Each role grants permissions, which are exact names, area:* or *, and can include other roles. Inclusion cycles are safe.

const policy: anole.auth.rbac.Policy = .{ .roles = &.{
.{ .name = "reader", .grants = &.{"notes:read"} },
.{ .name = "editor", .grants = &.{"notes:*"}, .includes = &.{"reader"} },
.{ .name = "admin", .grants = &.{"*"} },
} };
policy.allows("editor", "notes:write"); // true

rbac.Require is a middleware that answers 403 unless one of the request’s roles grants permission. Your roles function decides where the roles come from: the session, a token’s claims, an API key’s owner or your app’s state. It gets the ptr you pass with it, and returns a slice in req.arena or longer-lived memory. An error from it becomes a 500.

fn rolesOf(ctx: ?*anyopaque, req: *anole.Request) anyerror![]const []const u8 {
const app: *App = @ptrCast(@alignCast(ctx.?));
return app.rolesOfUser(req.arena, req.session.get("user") orelse return &.{});
}
var can_write = anole.auth.rbac.Require.init(&policy, "notes:write", .{ .ptr = &app, .roles = rolesOf });

anole.ratelimit keeps a token bucket per key. Policy allows limit requests per window, with a bucket of burst, which defaults to limit.

ratelimit.Middleware sets the RateLimit-Limit, RateLimit-Remaining and RateLimit-Reset headers on every response. Once a bucket is empty, it answers 429 with Retry-After.

var limiter = anole.ratelimit.MemoryLimiter.init(gpa, app.io(), .{}); // max_entries 10_000, idle 5 minutes
defer limiter.deinit();
var login_limit = anole.ratelimit.Middleware.init(limiter.limiter(), .{ .limit = 5, .window = .fromSeconds(60) });
var per_key = anole.ratelimit.Middleware.init(limiter.limiter(), .{ .limit = 1000, .key = byApiKeyOwner, .on_error = .deny });
  • MemoryLimiter is for one process. It holds at most max_entries keys and forgets a key idle for idle once its bucket is full again. When the table is full, a new key takes the place of a least recently used bucket that is full again, else of one that still has tokens; a throttled bucket is never pushed out, so a flood of new keys can’t refill it. If every bucket near that end is throttled, new keys share one overflow bucket until room frees up.
  • RedisLimiter (.{ .client = redis, .gpa = gpa } with redis from app.redis(.{}), keys anole:rl:<key>) is shared by every node. One Lua script refills and takes atomically, on the Redis server’s clock.
  • Your own limiter implements anole.ratelimit.Limiter: a vtable with take(ptr, key, cost, policy) !Result.

The key is the client’s address by default (ratelimit.clientAddress): an IPv4 address on its own, an IPv6 address by its /64 network, since one subscriber or host usually gets a whole /64 and could otherwise use a new address, and a fresh bucket, for every request. .key = anole.ratelimit.clientAddressPrefix(56) groups by another prefix (48 or 56 for providers that hand out larger blocks, 128 for each address alone). Behind a proxy, set .proxy = .{ .trusted_hops = 1 } in the server options so that the address comes from X-Forwarded-For, or turn on the listener’s proxy_protocol. Otherwise every client shares the proxy’s bucket. To limit by something else, such as an account or an API key, pass .key, a fn (req: *anole.Request, buf: []u8) []const u8 that returns the key, written into buf (128 bytes) or pointing into the request.

Key only on an identity that was checked, never on a raw header: a client picks any header value it likes, and each new value is a fresh bucket. Put the rate limit after the authentication middleware and use what it verified (anole.auth.apikey.owner(req), anole.auth.subject(req)), falling back to the client’s address:

// After anole.auth.apikey.Middleware, so the owner is a key that exists.
fn byApiKeyOwner(req: *anole.Request, buf: []u8) []const u8 {
if (anole.auth.apikey.owner(req)) |owner| {
return std.fmt.bufPrint(buf, "key:{s}", .{owner}) catch owner;
}
return anole.ratelimit.clientAddress(req, buf);
}

If the limiter fails, for example because Redis is down, .on_error decides: .allow (the default) lets the request through, .deny answers 503 Service Unavailable. A warning is logged either way. Deny where an unlimited burst costs more than an outage, such as a login.

Terminal window
~/.local/zig/0.17.0/zig build run-auth -- 8084

It prints a demo JWT for alice and a demo API key. Every secret in it is made at startup, so none survives a restart. The users are alice (editor) and bob (reader), both with the password demo-password.

Terminal window
B=http://localhost:8084
T=$(curl -s -D - -c jar -b jar $B/login -o /dev/null | awk -F': ' 'tolower($1)=="x-csrf-token"{print $2}' | tr -d '\r')
curl -c jar -b jar -d user=alice -d password=demo-password $B/login # 403: no CSRF token
curl -c jar -b jar -d user=alice -d password=demo-password -d _csrf=$T $B/login # signed in as alice
T=$(curl -s -c jar -b jar $B/me | awk '/^csrf:/{print $2}') # a fresh token
curl -c jar -b jar -X POST -H "X-CSRF-Token: $T" $B/notes # 201; bob gets 403 (reader)
curl -H "Authorization: Bearer <the printed JWT>" $B/api/me # token subject: alice
curl -H "X-API-Key: <the printed key>" $B/service/stats # stats for reporting-service
for i in 1 2 3 4 5 6; do curl -s -o /dev/null -w '%{http_code} ' -d user=x -d password=y $B/login; done
# 403 403 403 429 429 429: five POSTs a minute per address, the two above included