Zornux docs
Get started Spec

Web & APIs

API Authentication

The Security and Authorization pages cover the language primitives. This page puts them together into the patterns you actually need: JWT login flows, API key gating, OAuth integration, refresh tokens, and role-based access control for a real API.

JWT authentication

The most common API authentication pattern: a client sends credentials, receives a signed JWT, and includes it in subsequent requests.

Login endpoint

zornux
controller Auth at "/auth"
    on POST "/login" with body
        create email = sanitize(body.email)
        create password = body.password

        create user = from UserDb.users
            where email is equal to email
            select one

        if user is nothing
            give back status 401 with message "Invalid credentials"
        end

        if not verify_password(password, user.password_hash)
            give back status 401 with message "Invalid credentials"
        end

        create token = sign_jwt({
            "sub": user.id,
            "email": user.email,
            "role": user.role,
            "exp": now() + 1 hour
        })

        create refresh = sign_jwt({
            "sub": user.id,
            "type": "refresh",
            "exp": now() + 30 days
        })

        give back ok {
            "access_token": token,
            "refresh_token": refresh,
            "expires_in": 3600
        }
    end
end

Protected routes

Use restrict to Authenticated to require a valid token. The runtime verifies the JWT signature and expiry before the route body runs:

zornux
controller Profile at "/me"
    require authentication

    on GET "/"
        give back ok {
            "id": request.user.id,
            "email": request.user.email,
            "role": request.user.role
        }
    end
end

controller Tasks at "/tasks"
    require authentication

    on GET "/"
        create user_tasks = from TaskDb.tasks
            where user_id is equal to request.user.id
            select all
        give back ok user_tasks
    end
end

web TaskApi
    pipeline
        authenticate with jwt secret config.jwt_secret
    end

    use Profile
    use Tasks
end
request.user

When a JWT is validated, the decoded claims are available as request.user. The sub claim becomes request.user.id, and any other claims are accessible by name.

Token refresh

When the access token expires, the client sends the refresh token to get a new pair:

zornux
controller Auth at "/auth"
    on POST "/refresh" with body
        create refresh_token = body.refresh_token
        if refresh_token is nothing
            give back status 400 with message "Refresh token is required"
        end

        try
            create claims = verify_jwt(refresh_token, config.jwt_secret)

            if claims.type is not "refresh"
                give back status 401 with message "Invalid token type"
            end

            create user = from UserDb.users
                where id is equal to claims.sub
                select one

            if user is nothing
                give back status 401 with message "User not found"
            end

            create new_token = sign_jwt({
                "sub": user.id,
                "email": user.email,
                "role": user.role,
                "exp": now() + 1 hour
            })

            give back ok {
                "access_token": new_token,
                "expires_in": 3600
            }
        catch error
            give back status 401 with message "Invalid or expired refresh token"
        end
    end
end

API key authentication

For machine-to-machine APIs and third-party integrations, API keys are simpler than JWT:

zornux
controller DataExport at "/data"
    on GET "/export"
        create api_key = request.headers["X-API-Key"]
        if api_key is nothing
            give back status 401 with message "API key required"
        end

        create key_record = from ApiKeyDb.api_keys
            where key_hash is equal to hash(api_key)
                and active is true
            select one

        if key_record is nothing
            give back status 403 with message "Invalid API key"
        end

        # log usage for billing and auditing
        audit "api_access" with {
            "key_id": key_record.id,
            "client": key_record.client_name,
            "endpoint": "/data/export"
        }

        create data = from DataDb.records select all
        give back ok data
    end
end
Hash stored keys

Store API keys as hashes, not plaintext. When a client sends a key, hash the input and compare against the stored hash. If your database leaks, the keys are worthless without the originals.

Key management endpoints

zornux
controller ApiKeyAdmin at "/admin/api-keys"
    require role "admin"

    on POST "/" with body
        create raw_key = generate_api_key()
        create key_record = insert into ApiKeyDb.api_keys with {
            "key_hash": hash(raw_key),
            "client_name": sanitize(body.client_name),
            "scopes": body.scopes,
            "active": true
        }

        # return the raw key once — it cannot be retrieved later
        give back created {
            "key_id": key_record.id,
            "api_key": raw_key,
            "client_name": key_record.client_name,
            "message": "Save this key — it will not be shown again"
        }
    end

    on DELETE "/:id"
        update ApiKeyDb.api_keys
            where id is equal to id
            set active to false
        give back no content
    end
end

OAuth 2.0

Let users sign in with an external provider (Google, GitHub, etc.). Zornux's security model has built-in OAuth support:

zornux
controller OAuth at "/auth"
    authenticate with oauth
        provider google
            client_id config.google_client_id
            client_secret config.google_client_secret
            scopes "openid", "email", "profile"
        end
    end

    on GET "/google"
        # redirects the user to Google's consent screen
        give back oauth_redirect("google")
    end

    on GET "/google/callback"
        # exchange the authorization code for user info
        create google_user = oauth_callback("google")

        # find or create local user
        create user = from UserDb.users
            where oauth_provider is "google"
                and oauth_id is equal to google_user.id
            select one

        if user is nothing
            user = insert into UserDb.users with {
                "email": google_user.email,
                "name": google_user.name,
                "oauth_provider": "google",
                "oauth_id": google_user.id,
                "role": "user"
            }
        end

        create token = sign_jwt({
            "sub": user.id,
            "email": user.email,
            "role": user.role,
            "exp": now() + 1 hour
        })

        give back ok { "access_token": token }
    end
end

Role-based access control

Combine restrict to with roles embedded in the JWT to control access at the route level:

zornux
controller Profile at "/profile"
    require authentication

    on GET "/"
        give back ok request.user
    end
end

controller Articles at "/articles"
    require authentication

    on PUT "/:id" with body
        restrict to Editor otherwise give back status 403
        create article = update ArticleDb.articles
            where id is equal to id
            set title to sanitize(body.title),
                content to sanitize(body.content)
        give back ok article
    end
end

controller AdminUsers at "/admin/users"
    require role "admin"

    on GET "/"
        create users = from UserDb.users select all
        give back ok users
    end

    on DELETE "/:id"
        delete from UserDb.users where id is equal to id
        give back no content
    end
end

web AdminApp
    pipeline
        authenticate with jwt secret config.jwt_secret
    end

    use Profile
    use Articles
    use AdminUsers
end

For fine-grained control, use policy blocks:

zornux
policy CanEditArticle
    require role is "admin" or role is "editor"
    check
        create article = from ArticleDb.articles
            where id is equal to resource.id
            select one
        if article is nothing
            auth.deny("Article not found")
        end
        if role is "editor" and article.author_id is not equal to request.user.id
            auth.deny("Editors can only edit their own articles")
        end
    end
end

controller Articles at "/articles"
    on PUT "/:id" with body
        restrict to CanEditArticle otherwise give back status 403
        # ...
    end
end

Registration and password hashing

zornux
controller Auth at "/auth"
    on POST "/register" with body
        create email = sanitize(body.email)

        # check for existing account
        create existing = from UserDb.users
            where email is equal to email
            select one
        if existing is not nothing
            give back status 409 with message "Email already registered"
        end

        create hashed = hash_password(body.password)

        create user = insert into UserDb.users with {
            "email": email,
            "name": sanitize(body.name),
            "password_hash": hashed,
            "role": "user"
        }

        give back created {
            "id": user.id,
            "email": user.email,
            "message": "Account created"
        }
    end
end
hash_password and verify_password

These built-in functions use a strong adaptive hash (bcrypt by default). Never store plaintext passwords or use a general-purpose hash like SHA-256 for password storage.

Putting it all together

A production API combines authentication, authorization, validation, and observability into a coherent stack:

zornux
service ProductService
    async function list_products
        create products = from ProductDb.products select all
        give back products
    end

    async function create_product with body, user_id
        create request = validate(body, CreateProductRequest)
        if request.errors is not nothing
            give back status 400 with request.errors
        end

        create product = insert into ProductDb.products with {
            "name": request.name,
            "price": request.price,
            "category": request.category
        }

        audit "product_created" with { "id": product.id, "by": user_id }
        give back product
    end
end

controller Products at "/products"
    use ProductService

    on GET "/"
        # public — no restriction
        create products = wait for ProductService.list_products()
        give back ok products
    end

    on POST "/" with body
        restrict to Admin otherwise give back status 403
        create product = wait for ProductService.create_product(body, request.user.id)
        give back created product
    end
end

web ProductApi
    pipeline
        authenticate with jwt secret config.jwt_secret
        cors allow origins config.allowed_origins
        rate limit 200 per minute by client_ip
        log requests
        catch errors
    end

    use Products
end

publish ProductApi on port 8080

What's next

  • Building APIs — the step-by-step tutorial from an empty project to a working CRUD API.
  • API Patterns — pagination, filtering, file uploads, versioning, and caching.
  • Security — the language-level security model, trust-aware text, and the security scanner.
  • Authorization — policy blocks, claims, and resource-based access control.