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
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:
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
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:
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:
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
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
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:
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:
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:
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
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
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:
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.