Back to flin
flin

JWT Authentication in 3 Lines of FLIN

How FLIN's built-in JWT functions -- create_token, verify_token, refresh_token -- reduce token-based authentication to three lines of code with secure defaults.

Thales & Claude | March 25, 2026 8 min flin
flinjwtauthenticationtokens

JSON Web Tokens have become the standard for stateless authentication in web APIs. The concept is simple: create a signed token containing the user's identity, send it to the client, and verify it on every request. The implementation, in most frameworks, is anything but simple.

In a typical Node.js application, JWT authentication requires installing jsonwebtoken, express-jwt, and jwks-rsa. You generate a secret key, configure signing options, write middleware to extract and verify tokens, handle expired tokens, implement refresh logic, and manage token revocation. The "simple" concept becomes 200+ lines of configuration and boilerplate spread across multiple files.

FLIN reduces this to three built-in functions:

flin// Create a token
token = create_token(user, { expires: "7d" })

// Verify a token
claims = verify_token(token)

// Refresh a token
new_token = refresh_token(old_token)

Three lines. Three functions. The signing algorithm, the secret key management, the expiration handling, and the claim structure are all handled by the runtime.

Creating Tokens

The create_token() function generates a signed JWT:

flin// Minimal: just the user entity
token = create_token(user)
// Default expiration: 24 hours

// With custom expiration
token = create_token(user, { expires: "7d" })

// With custom claims
token = create_token(user, {
    expires: "30d",
    claims: {
        role: user.role,
        org_id: user.org_id,
        plan: user.plan
    }
})

The generated token contains standard JWT claims:

json{
    "sub": "42",
    "email": "[email protected]",
    "name": "Thales Gnimavo",
    "role": "admin",
    "iat": 1711411200,
    "exp": 1712016000,
    "iss": "flin"
}

The sub (subject) claim is always the user's ID. The iat (issued at) and exp (expiration) claims are set automatically. Custom claims from the claims option are merged into the payload.

Verifying Tokens

The verify_token() function decodes and validates a JWT:

flinclaims = verify_token(token)
// Returns the decoded claims if valid, none if invalid

if claims == none {
    return error(401, "Invalid or expired token")
}

user_id = claims.sub
user_role = claims.role

Verification checks:

  1. The signature is valid (the token was not tampered with).
  2. The token has not expired (exp is in the future).
  3. The token was issued by this application (iss matches).
  4. The token structure is valid (three Base64-encoded segments).

If any check fails, verify_token returns none. There is no exception to catch, no error code to decode. Either the token is valid and you get claims, or it is not and you get none.

The Authentication Middleware Pattern

The most common use of JWT in FLIN is an authentication middleware that extracts the token from the Authorization header:

flin// app/api/_middleware.flin

middleware {
    auth_header = headers["Authorization"]

    if auth_header != "" && auth_header.starts_with("Bearer ") {
        token = auth_header.slice(7)
        claims = verify_token(token)

        if claims != none {
            request.user = claims
            request.user_id = to_int(claims.sub)
        }
    }

    next()
}

This middleware does not reject unauthenticated requests -- it simply attaches the user information if a valid token is present. Route-level guards handle the rejection:

flin// app/api/profile.flin

guard auth    // Rejects if request.user is not set

route GET {
    User.find(request.user_id)
}

This separation means public endpoints (like listing products) work without authentication, while protected endpoints (like viewing a profile) are guarded explicitly.

The Complete Login Flow

Here is a complete JWT-based login endpoint:

flin// app/api/auth/login.flin

guard rate_limit(5, 60)    // 5 attempts per minute

route POST {
    validate {
        email: text @required @email
        password: text @required
    }

    user = User.where(email == body.email).first

    // Same error for both "not found" and "wrong password"
    if user == none || !verify_password(body.password, user.password) {
        return error(401, "Invalid credentials")
    }

    // Check account lockout
    if user.locked_until != none && user.locked_until > now {
        return error(423, "Account locked. Try again later.")
    }

    // Reset login attempts on success
    user.login_attempts = 0
    save user

    // Create tokens
    access_token = create_token(user, {
        expires: "15m",
        claims: { role: user.role }
    })

    refresh_token_val = create_token(user, {
        expires: "7d",
        claims: { type: "refresh" }
    })

    {
        access_token: access_token,
        refresh_token: refresh_token_val,
        expires_in: 900,
        user: {
            id: user.id,
            name: user.name,
            email: user.email,
            role: user.role
        }
    }
}

The response contains both an access token (short-lived, 15 minutes) and a refresh token (long-lived, 7 days). The access token is sent with every API request. When it expires, the client uses the refresh token to get a new access token.

Token Refresh

The refresh_token() function validates an existing token and issues a new one with a fresh expiration:

flin// app/api/auth/refresh.flin

route POST {
    validate {
        refresh_token: text @required
    }

    claims = verify_token(body.refresh_token)
    if claims == none {
        return error(401, "Invalid refresh token")
    }

    if claims.type != "refresh" {
        return error(401, "Not a refresh token")
    }

    user = User.find(to_int(claims.sub))
    if user == none {
        return error(401, "User not found")
    }

    new_access = create_token(user, {
        expires: "15m",
        claims: { role: user.role }
    })

    new_refresh = create_token(user, {
        expires: "7d",
        claims: { type: "refresh" }
    })

    {
        access_token: new_access,
        refresh_token: new_refresh,
        expires_in: 900
    }
}

The refresh endpoint validates the refresh token, checks that it is indeed a refresh token (not an access token being reused), looks up the user, and issues fresh tokens. If the user has been deleted or deactivated since the refresh token was issued, the refresh fails.

Signing and Secret Management

FLIN uses HMAC-SHA256 for token signing by default. The signing secret is derived from the application's JWT_SECRET environment variable:

flin// .env
JWT_SECRET=a-very-long-random-string-at-least-256-bits

If JWT_SECRET is not set, FLIN generates a random secret at startup and logs a warning. This means development works without configuration, but production requires an explicit secret.

The implementation in Rust:

rustuse hmac::{Hmac, Mac};
use sha2::Sha256;

type HmacSha256 = Hmac<Sha256>;

fn sign_token(header: &str, payload: &str, secret: &[u8]) -> String {
    let message = format!("{}.{}", header, payload);
    let mut mac = HmacSha256::new_from_slice(secret)
        .expect("HMAC can take key of any size");
    mac.update(message.as_bytes());
    let signature = mac.finalize().into_bytes();
    base64url_encode(&signature)
}

fn verify_signature(token: &str, secret: &[u8]) -> bool {
    let parts: Vec<&str> = token.splitn(3, '.').collect();
    if parts.len() != 3 { return false; }

    let expected = sign_token(parts[0], parts[1], secret);
    constant_time_eq(parts[2].as_bytes(), expected.as_bytes())
}

The constant_time_eq function prevents timing attacks on signature verification. An attacker cannot determine how many bytes of their forged signature are correct by measuring response time.

Session-Based vs Token-Based Authentication

FLIN supports both session-based and token-based authentication. The choice depends on the application:

Session-based (cookie-based) is better for traditional web applications where the frontend and backend are on the same domain. FLIN's session system handles this automatically through the session object.

Token-based (JWT) is better for APIs consumed by mobile apps, SPAs on different domains, or third-party integrations. FLIN's JWT functions handle this.

Many FLIN applications use both: session-based for the web interface and JWT for the API. The middleware can check both:

flinmiddleware {
    // Check session first
    if session.user != "" {
        user = User.where(email == session.user).first
        if user != none {
            request.user = user
        }
    }

    // Check JWT if no session
    if request.user == none {
        auth = headers["Authorization"]
        if auth != "" && auth.starts_with("Bearer ") {
            claims = verify_token(auth.slice(7))
            if claims != none {
                request.user = claims
                request.user_id = to_int(claims.sub)
            }
        }
    }

    next()
}

Security Guarantees

FLIN's JWT implementation provides several guarantees that are not optional:

No unsigned tokens. There is no algorithm: "none" option. Every token is signed.

No weak algorithms. The minimum signing algorithm is HMAC-SHA256. There is no MD5, no SHA-1, no RSA with less than 2048 bits.

Mandatory expiration. Every token has an expiration time. If you do not specify one, the default (24 hours) is used. There is no way to create a token that never expires.

Constant-time verification. Signature comparison always takes the same amount of time, regardless of how many bytes match.

Automatic claim validation. The verify_token function checks exp, iss, and signature validity. You cannot forget to check the expiration.

These guarantees mean that a FLIN developer who uses create_token and verify_token gets secure JWT authentication without needing to understand the security implications of algorithm selection, timing attacks, or token validation.

In the next article, we cover rate limiting and automatic security headers -- the invisible protections that harden every FLIN application against abuse and common web attacks.


This is Part 108 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: - [107] Argon2 Password Hashing Built Into FLIN - [108] JWT Authentication in 3 Lines of FLIN (you are here) - [109] Rate Limiting and Security Headers - [110] Two-Factor Authentication (TOTP)

Share this article:

Responses

Write a response
0/2000
Loading responses...

Related Articles