Skip to content

Auth, JWT & Rate Limiting

Decorators

A decorator runs before a handler. It can reject the request with fail STATUS "msg" (short-circuiting to an error response) and/or assign the typed bindings in its injects (...) clause, which then become variables inside the handler. This keeps auth a declaration rather than boilerplate in every handler.

weld
type Claims = { sub: string, role: string }

decorator require_role(role: string) injects (user: Claims) {
    let header = request_header("Authorization") else { fail 401 "missing authorization" }
    user = try jwt_verify(Claims, trimPrefix(header, "Bearer "), env("JWT_SECRET"))
    if user.role != role { fail 403 "forbidden" }
}

@require_role("admin")
route GET "/admin" -> Claims {
    respond user                     # `user` injected by the decorator, typed as Claims
}

Decorators stack and run top-to-bottom; each is inlined into the handler, so there is no per-request indirection.

JWT & request helpers

  • jwt_sign(claims, secret) -> string — sign a claims record as an HS256 token, so a service can issue its own tokens, not just verify them.
  • jwt_verify(Type, token, secret) -> Type! — HS256-verifies the signature (constant-time) and decodes the payload JSON into your claims record. It also enforces the standard exp (expiry) and nbf (not-before) claims when your record carries them as int fields, so an expired token is rejected automatically (a failed try → 401).
  • jwt_decode(Type, token) -> Type? — decodes without verifying.
  • request_header(name) -> string? — read a request header.
  • client_ip() -> string — reads X-Forwarded-For / X-Real-IP.

Passwords & sessions

password_hash(pw) -> string computes a bcrypt hash with a random salt; verify a candidate against a stored hash with password_verify(pw, hash) -> bool (constant-time). Together with jwt_sign this is a complete login: hash on register, verify on login, then hand back a signed token.

weld
type Credentials = { username: string, password: string }
type Claims = { sub: string, exp: int }
type Session = { token: string }

state accounts { var hashes: [string: string] = [:] }
let secret = env("JWT_SECRET")

route POST "/register" (body creds: Credentials) -> Session {
    accounts.hashes[creds.username] = password_hash(creds.password)
    let claims = Claims { sub: creds.username, exp: now_ms() / 1000 + 3600 }
    respond Session { token: jwt_sign(claims, secret) }
}

route POST "/login" (body creds: Credentials) -> Session {
    let stored = accounts.hashes[creds.username] ?? ""
    if !password_verify(creds.password, stored) { fail 401 "invalid credentials" }
    let claims = Claims { sub: creds.username, exp: now_ms() / 1000 + 3600 }
    respond Session { token: jwt_sign(claims, secret) }   # verified later by jwt_verify
}

Crypto & encoding

Lower-level primitives, all in the standard prelude:

  • random_bytes(n) -> stringn cryptographically-secure random bytes (CSPRNG).
  • uuid() -> string — a random (v4) UUID.
  • sha256(s) -> string — SHA-256 digest, hex-encoded.
  • hmac_sha256(msg, key) -> string — HMAC-SHA-256, hex-encoded.
  • base64_encode / base64_decode and base64url_encode / base64url_decode — standard and URL-safe Base64.
  • hex_encode / hex_decode — hex. All decoders return "" on malformed input.

hash() is Wyhash — fast but non-cryptographic; reach for password_hash or sha256 whenever the result is security-sensitive.

Rate limiting

Rate limiting is written in Weld itself on top of decorators + state + a map — no built-in primitive. A [string: Bucket] map keyed by client IP holds a per-client fixed-window counter:

weld
type Bucket = { count: int, window_start: int }
state limiter { var buckets: [string: Bucket] = [:] }

decorator rate_limit(max: int, window_ms: int) {
    let ip = client_ip()
    let now = now_ms()
    let b = limiter.buckets[ip] ?? Bucket { count: 0, window_start: now }
    var count = b.count
    var start = b.window_start
    if now - start > window_ms { start = now count = 0 }
    count = count + 1
    limiter.buckets[ip] = Bucket { count: count, window_start: start }
    if count > max { fail 429 "rate limit exceeded for {ip}" }
}

@rate_limit(60, 60000)               # 60 requests / minute, per client IP
route GET "/api/data" { respond "ok" }