Back to flin
flin

Guards: Declarative Security for Routes

How FLIN's guard system provides declarative, composable access control for routes -- auth, roles, rate limiting, CSRF, IP whitelists, and time-based access in single-line declarations.

Thales & Claude | March 25, 2026 7 min flin
flinguardssecuritydeclarative

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

Share this article:

Responses

Write a response
0/2000
Loading responses...

Related Articles