ywt/claim

Types

A Claim is a validation rule on the JWT. You can for example validate that the issuer has a value you expect, or that the JWT is not expired yet.

pub opaque type Claim

Values

pub fn audience(primary: String, others: List(String)) -> Claim

Validates that the JWT is intended for your service, and includes the primary audience in generated tokens.

Include this to ensure JWTs are only used by their intended recipients, preventing tokens meant for other services from being accepted.

Example

// For an API service
let claims = [
  claim.audience("api.myapp.com", ["admin-api.myapp.com"]),
]

💡 Note: Even without this claim, tokens containing an aud field are rejected — a token scoped to a specific audience should not be accepted by a service that isn’t checking audiences. Add this claim to accept tokens with a matching audience.

pub fn custom(
  name name: String,
  value value: a,
  encode encode: fn(a) -> json.Json,
  decoder decoder: decode.Decoder(a),
) -> Claim

Creates a custom claim with a specific name and value that must match exactly during validation.

Use this for application-specific claims like roles, permissions, or custom business logic requirements.

Example

// Add role-based authorization
let claims = [
  claim.custom(
    name: "role",
    value: "admin",
    encode: json.string,
    decoder: decode.string
  ),
  claim.custom(
    name: "permissions",
    value: ["read", "write", "delete"],
    encode: json.array(_, json.string),
    decoder: decode.list(decode.string)
  ),
  claim.expires_at(max_age: duration.hours(1), leeway: duration.minutes(5))
]
pub fn encode(
  claims: List(Claim),
  data: List(#(String, json.Json)),
) -> json.Json

Encode claims and application data into JSON.

This function merges application data with JWT claims into a single JSON object for token encoding. When the same field appears in both lists, claims take precedence to ensure security-critical values cannot be overridden.

🚨 This is a low-level function used internally by ywt.

Example

let user_data = [
  #("name", json.string("Alice")),
  #("role", json.string("user")),
  #("exp", json.int(1234567890))  // This will be overridden
]

let security_claims = [
  expires_at(max_age: duration.hours(1), leeway: duration.minutes(5)),
  custom("role", "admin", json.string, decode.string)  // This takes precedence
]

let payload = encode(security_claims, user_data)
// Result: {"name": "Alice", "role": "admin", "exp": <actual_expiry>}

💡 Best Practice: Place security-sensitive fields in claims rather than data to validate them and ensure they cannot be accidentally overridden.

pub fn encode_numeric_date(
  timestamp: timestamp.Timestamp,
) -> json.Json

Encodes a timestamp into the JWT numeric date format (seconds since Unix epoch).

Use this when manually creating JWT payloads with timestamp values.

Example

// Add custom timestamp to payload
let payload = [
  #("custom_date", encode_numeric_date(my_timestamp))
]
pub fn expires_at(
  max_age max_age: duration.Duration,
  leeway leeway: duration.Duration,
) -> Claim

Sets an expiration time for the JWT, automatically rejecting expired tokens.

ALWAYS include this - it’s your primary defense against token replay attacks and limits the damage from compromised tokens.

Security Considerations

  • Make expiration times as short as reasonably possible
  • Include reasonable leeway for clock differences

Example

// Short-lived API access token
let api_claims = [
  claim.expires_at(max_age: duration.minutes(30), leeway: duration.minutes(5))
]

// Longer-lived refresh token
let session_claims = [
  claim.expires_at(max_age: duration.hours(24 * 7), leeway: duration.minutes(5))
]

🚨 Critical: Never create or parse JWTs without expiration times - they would be valid forever if compromised.

💡 Note: Even without this claim, tokens containing an exp field are still checked for expiration with zero leeway. Add this claim explicitly to control the max_age and leeway.

pub fn id(id: String, others: List(String)) -> Claim

Adds a unique identifier (jti) to the JWT for tracking and revocation.

Use this when you need to track specific tokens or implement token revocation mechanisms.

pub fn issued_at() -> Claim

Adds the current timestamp as the “issued at” (iat) claim.

Include this in JWTs to track when tokens were issued, useful for debugging and audit trails. To reject tokens that were created in the future, use not_before.

Example

let claims = [
  claim.expires_at(max_age: duration.hours(1), leeway: duration.minutes(5)),
  ...
]
pub fn issuer(issuer: String, others: List(String)) -> Claim

Validates that the JWT comes from a trusted issuer, and includes that issuer in generated tokens.

Use this to ensure JWTs are only accepted from authorized authentication services, preventing token misuse across different services. It is common practice to set the issuer to a URL with a .well-known/jwks.json endpoint, from which public verification keys can be fetched.

Example

// Only accept tokens from your auth service
let claims = [
  claim.issuer("https://auth.example.com", [])
]
pub fn not_before(
  time time: timestamp.Timestamp,
  leeway leeway: duration.Duration,
) -> Claim

Adds a “not before” (nbf) claim that prevents the JWT from being valid until the current time.

Use this for JWTs that should only become valid at a specific time, such as scheduled operations or delayed activations.

Example

// Token becomes valid immediately, allowing 1 minute clock skew
let claims = [
  claim.not_before(timestamp.system_time(), leeway: duration.minutes(1)),
  claim.expires_at(max_age: duration.hours(2), leeway: duration.minutes(1))
]

💡 Note: Even without this claim, tokens containing an nbf field are still checked against the current time with zero leeway. Add this claim explicitly to control the leeway.

pub fn numeric_date_decoder() -> decode.Decoder(
  timestamp.Timestamp,
)

Decodes JWT numeric date values (seconds since Unix epoch) into timestamp objects.

Use this when manually processing JWT claims that contain timestamp values.

Example

// Manually decode an expiration claim
let exp_decoder = decode.field("exp", numeric_date_decoder())
pub fn optional(claim: Claim) -> Claim

Marks a claim as optional, meaning the JWT will still be considered valid even if this claim is missing from the token. By default, all claims are required — a missing required claim causes validation to fail with a MissingClaim error.

If the claim is already optional, it is returned unchanged.

Example

// Accept tokens with or without a "jti" (token ID)
let claims = [
  claim.id("abc-123", []) |> claim.optional,
  claim.expires_at(max_age: duration.hours(1), leeway: duration.minutes(5))
]
pub fn subject(subject: String, others: List(String)) -> Claim

Identifies the subject (typically a user) that the JWT represents.

Use this to bind JWTs to specific users or entities, enabling proper authorization decisions. This could be an internal user id.

Security Considerations

  • Use stable, unique identifiers (user IDs, not usernames)
  • Don’t include personally identifiable information

Example

// Bind token to a specific user
let claims = [
  claim.subject("user_12345", []),
  claim.expires_at(max_age: duration.hours(1), leeway: duration.minutes(5)),
  claim.audience("api.myapp.com", [])
]
pub fn typ(expected: String) -> Claim

Validates that the JWT header has a specific typ (type) field, and includes it in generated tokens.

The typ header parameter is used to declare the media type of the JWT. While RFC 7519 makes this field optional, many systems expect "JWT" as the standard value for JSON Web Tokens. Use this claim when integrating with systems that require explicit type declaration in the JWT header.

Example

// Standard JWT type validation
let claims = [
  claim.typ("JWT"),
  claim.expires_at(max_age: duration.hours(1), leeway: duration.minutes(5))
]
pub fn verify(
  payload: dynamic.Dynamic,
  claims: List(Claim),
) -> Result(Nil, @internal ParseError)

Validates all claims against a JWT payload.

This function checks each claim in the provided list against the JWT payload, returning the first validation error encountered or success if all claims pass.

🚨 This is a low-level function used internally by ywt.

Default Claim Validation

Even if you don’t explicitly include exp, nbf, or aud claims, they will still be validated when present in the token:

  • exp (expiration): If present, the token must not be expired (zero leeway).
  • nbf (not before): If present, the token must be valid at the current time (zero leeway).
  • aud (audience): If present, the token is rejected.

This prevents accepting tokens that carry security constraints you forgot to enforce. To customise validation behaviour (e.g. allowing leeway), add the corresponding claim explicitly — your claims always take precedence over the defaults.

Example

let payload = // ... decoded JWT payload

let security_claims = [
  expires_at(max_age: duration.hours(1), leeway: duration.minutes(5)),
  issuer("https://auth.myapp.com", []),
  audience("https://api.myapp.com", []),
  custom("role", "admin", json.string, decode.string)
]

case verify(payload, security_claims) {
  Ok(Nil) -> {
    // All claims validated successfully
    process_authenticated_request(payload)
  }
  Error(TokenExpired(expired_at)) -> {
    log.info("Token expired at: " <> timestamp.to_string(expired_at))
    return_authentication_error()
  }
  Error(InvalidIssuer(expected, actual)) -> {
    log.warning("Invalid issuer - expected: " <> expected <> ", got: " <> actual)
    return_forbidden_error()
  }
  Error(error) -> {
    log.error("Claim validation failed: " <> string.inspect(error))
    return_bad_request_error()
  }
}

⚠️ Important: This function only validates claims - it does not verify cryptographic signatures. Use the full decode() function for complete JWT validation.

Search Document