Middleware handles cross-cutting concerns at the directory level. But sometimes you need fine-grained access control at the route level. An admin panel might require authentication for all routes (middleware), but only allow the DELETE method for users with the "superadmin" role. A public API might allow unlimited GET requests but rate-limit POST requests to 10 per minute.
FLIN's guard system provides this granularity. Guards are one-line declarations at the top of a route file or inside a route block that enforce security constraints before the handler executes. If a guard fails, the request is rejected with an appropriate HTTP status code. The handler never runs.
Guard Syntax
Guards are declared with the guard keyword followed by the guard name and optional parameters:
flin// app/api/admin/users.flin
guard auth // Must be authenticated
guard role("admin", "superadmin") // Must have one of these roles
guard rate_limit(50, 60) // 50 requests per 60 seconds
route GET {
User.all
}
route DELETE {
guard owner(params.id) // Additional guard for DELETE only
user = User.find(params.id)
delete user
{ success: true }
}File-level guards apply to every route block in the file. Route-level guards apply only to their specific block. Both are evaluated before the handler code runs.
The Nine Built-in Guards
FLIN ships with nine guards that cover the most common access control patterns:
guard auth
Requires the request to be authenticated. Checks session.user or the Authorization header for a valid token.
flinguard auth
route GET {
// Only authenticated users reach here
User.find(session.userId)
}Failure response: 401 Unauthorized
guard role
Requires the authenticated user to have one of the specified roles:
flinguard auth
guard role("admin", "moderator")
route GET {
// Only admins and moderators
User.all
}Failure response: 403 Forbidden
guard owner
Requires the authenticated user to own the resource being accessed, or to be an admin:
flinguard auth
guard owner(params.id)
route PUT {
user = User.find(params.id)
user.name = body.name
save user
user
}The owner guard compares the authenticated user's ID with the resource's user_id or id field. Admins bypass the check.
Failure response: 403 Forbidden
guard rate_limit
Enforces rate limiting based on the client's IP address:
flinguard rate_limit(10, 60) // 10 requests per 60 seconds
route POST {
// Protected from abuse
}The rate limiter uses a sliding window algorithm. The first parameter is the maximum number of requests, the second is the window duration in seconds.
Failure response: 429 Too Many Requests with Retry-After header
guard csrf
Requires a valid CSRF token in the request body or headers. Automatically added to forms when this guard is active:
flinguard csrf
route POST {
// CSRF token validated before this runs
}Failure response: 403 Forbidden
guard api_key
Requires a valid API key in the X-API-Key header:
flinguard api_key
route GET {
// Only requests with valid API key
}Failure response: 401 Unauthorized
guard ip
Restricts access to specific IP addresses:
flinguard ip("10.0.0.1", "10.0.0.2", "192.168.1.0/24")
route GET {
// Only from whitelisted IPs
}Failure response: 403 Forbidden
guard time
Restricts access to specific time windows:
flinguard time("09:00", "17:00")
route POST {
// Only during business hours (server timezone)
}Failure response: 403 Forbidden with message indicating when access is available
guard method
Restricts which HTTP methods are allowed:
flinguard method("GET", "POST")
route DELETE {
// This route is unreachable because DELETE is not in the allowed methods
}Failure response: 405 Method Not Allowed
Guard Composition
Guards compose with AND logic. All guards must pass for the handler to execute:
flinguard auth // 1. Must be authenticated
guard role("admin") // 2. Must be admin
guard rate_limit(10, 60) // 3. Under rate limit
guard time("09:00", "17:00") // 4. During business hours
route DELETE {
// Requires ALL four conditions to be true
user = User.find(params.id)
delete user
{ success: true }
}Guards are evaluated in declaration order. If guard #2 fails, guards #3 and #4 are not evaluated. This short-circuit behavior is both a performance optimization and a security feature: a rate-limited unauthenticated request does not consume a rate limit token.
Custom Guards
The built-in guards cover common cases, but applications often need domain-specific access control. FLIN supports custom guard definitions:
flin// Define a custom guard
guard_definition verified_email {
user = request.user
if !user || !user.email_verified {
return response {
status: 403
body: { error: "Email not verified" }
}
}
}
guard_definition subscription(plan) {
user = request.user
if !user || user.plan != plan {
return response {
status: 403
body: { error: "Requires " + plan + " subscription" }
}
}
}Custom guards are used exactly like built-in guards:
flinguard auth
guard verified_email
guard subscription("pro")
route POST {
// Only verified pro subscribers
}Guard Implementation
Under the hood, guards compile to simple predicate functions that return either Ok(()) (pass) or Err(Response) (fail):
rustpub enum GuardResult {
Pass,
Fail(Response),
}
pub struct CompiledGuard {
name: String,
params: Vec<Value>,
check: fn(&RequestContext, &[Value]) -> GuardResult,
}
fn evaluate_guards(
guards: &[CompiledGuard],
ctx: &RequestContext,
) -> Result<(), Response> {
for guard in guards {
match (guard.check)(ctx, &guard.params) {
GuardResult::Pass => continue,
GuardResult::Fail(response) => return Err(response),
}
}
Ok(())
}The implementation for the auth guard is straightforward:
rustfn guard_auth(ctx: &RequestContext, _params: &[Value]) -> GuardResult {
// Check session
if ctx.session.get("user").is_some() {
return GuardResult::Pass;
}
// Check Authorization header (Bearer token)
if let Some(auth) = ctx.headers.get("authorization") {
if auth.starts_with("Bearer ") {
let token = &auth[7..];
if verify_jwt(token, &ctx.jwt_secret).is_ok() {
return GuardResult::Pass;
}
}
}
GuardResult::Fail(Response::unauthorized("Authentication required"))
}Guards vs Middleware: When to Use Which
Guards and middleware serve different purposes, and using the right tool matters:
Use middleware for cross-cutting concerns that apply to many routes: logging, CORS headers, request ID generation, session hydration. Middleware lives in _middleware.flin and applies to entire directory trees.
Use guards for access control on specific routes or methods: authentication, authorization, rate limiting, CSRF protection. Guards live in the route file and are visible alongside the code they protect.
The distinction is practical, not theoretical. Authentication checking could be done in middleware or guards. But placing it in a guard means the access requirement is visible when you read the route file -- you do not need to trace up the directory tree to find the middleware that enforces it.
flin// Clear: access requirements are visible in the file
guard auth
guard role("admin")
route DELETE {
delete User.find(params.id)
}Versus:
flin// Unclear: is this protected? Need to check _middleware.flin
route DELETE {
delete User.find(params.id)
}In practice, most FLIN applications use middleware for the auth check (redirect to login) and guards for role-based access control (return 403). This combination means unauthenticated users get redirected while authenticated-but-unauthorized users get a clear error.
Real-World Guard Patterns
Public API with Tiered Rate Limits
flin// app/api/search.flin
guard rate_limit(100, 60) // Free tier: 100/min
route GET {
if request.user && request.user.plan == "pro" {
// Pro users get higher limits (checked at app level)
}
search(query.q)
}Admin-Only Destructive Operations
flin// app/api/users/[id].flin
guard auth
route GET {
User.find(params.id) // Any authenticated user can read
}
route PUT {
guard owner(params.id) // Must own the resource to update
// ...
}
route DELETE {
guard role("admin") // Only admins can delete
// ...
}Time-Restricted Financial Operations
flin// app/api/transactions.flin
guard auth
guard role("accountant", "admin")
guard time("08:00", "18:00")
route POST {
// Financial transactions only during business hours
}Guards make security visible, composable, and impossible to forget. When a new developer reads a route file, the access requirements are the first thing they see. When a security auditor reviews the codebase, every protection is declared explicitly at the point of use.
In the next article, we explore FLIN's built-in WebSocket support -- real-time communication without a separate WebSocket server or library.
This is Part 102 of the "How We Built FLIN" series, documenting how a CEO in Abidjan and an AI CTO designed and built a programming language from scratch.
Series Navigation: - [100] Request Context Injection - [101] The Middleware System - [102] Guards: Declarative Security for Routes (you are here) - [103] WebSocket Support Built Into the Language