JWT Functions
Create, verify, and decode JSON Web Tokens for authentication.
Token Operations
jwt_sign(payload, secret, options?)
Create a signed JWT token.
Parameters
payload : Hash - The claims to encode in the tokensecret : String - The secret key for signing. Must be at least 32 bytes (SEC-054). Load a high-entropy value from .env, e.g. generate it once with openssl rand -hex 32 and reference it as getenv("JWT_SECRET"). Never commit the secret to source.options : Hash? - Optional settingsOptions
expires_in : Int - Token lifetime in seconds from nowalgorithm : String - HS256, HS384, HS512, RS256, or EdDSA (default: HS256)key : String - PEM-encoded private key for RS256/EdDSA algorithmskid : String - Key ID, written to the JWT header. Lets a verifier pick the right key out of a JWKS — this is what makes key rotation possible.typ : String - Overrides the header typ (default "JWT"). Use "at+jwt" for RFC 9068 access tokens.exp : Int - Expiration as an absolute Unix timestamp. Mutually exclusive with expires_in; supplying both raises, since they are different units.nbf : Int - Not-before, as an absolute Unix timestampaud : String | Array - Audience (RFC 7519 §4.1.3 allows either form)iss : String - Issuerjti : String - Unique token IDRegistered claims come from options; everything else in payload becomes a custom claim. sub is the exception — it is read from the payload. An option always wins over a same-named payload key.
token = jwt_sign(
{ "sub": "user123", "role": "admin" },
getenv("JWT_SECRET"),
{ "expires_in": 3600 }
)
# eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
token = jwt_sign(
{ "sub": "user123", "role": "admin" },
"ignored_for_rsa", # still required but unused when key is provided
{
"algorithm": "RS256",
"key": "-----BEGIN PRIVATE KEY-----\nMIIEvgIBADANBgkqhkiG9w0BBQEw...\n-----END PRIVATE KEY-----"
}
)
# kid lets the relying party find this key in your JWKS, so you can
# publish two keys and rotate without invalidating live tokens.
id_token = jwt_sign(
{ "sub": user["_key"], "email": user["email"] },
"",
{
"algorithm": "RS256",
"key": getenv("SOLI_OIDC_PRIVATE_KEY"),
"kid": active_kid,
"iss": "https://op.example",
"aud": client["client_id"],
"expires_in": 600
}
)
jwt_verify(token, secret, options?)
Verify and decode a JWT token. The verifier — not the token — chooses which algorithm is acceptable (SEC-091), closing the classic algorithm-confusion attack where an attacker who knew the verifier's RSA public key could sign an HS256 token using the public key bytes as an HMAC secret.
Parameters
token : String - The JWT token to verifysecret : String - The secret key (HMAC) or public key material (RSA/EdDSA). Same 32-byte minimum for HMAC algorithms (SEC-054).options : Hash? - Optional settingsOptions
algorithm : String - Pin verification to a specific algorithm (HS256, HS384, HS512, RS256, EdDSA). The token's header alg must match exactly or the call rejects.key : String - PEM-encoded public key for RS256/EdDSA algorithms. When key is provided without an explicit algorithm, the allowed set is RS256 / EdDSA only — HMAC tokens are rejected (the algorithm-confusion attack vector).audience : String | Array - Expected aud. Setting it makes aud a required claim, so a token without one cannot slip through a check you believed was enforced.issuer : String | Array - Expected iss, likewise required once set.subject : String - Expected subleeway : Int - Clock-skew tolerance in seconds (default 60)
Without options, the 2-arg form accepts only HMAC algorithms (HS256/HS384/HS512), matching the back-compat default for existing jwt_verify(token, secret) callers.
Audience is opt-in. aud is only checked when you pass audience; a token carrying an audience you never asked about verifies normally. Audience is caller-supplied policy, exactly like iss. If you issue tokens for more than one client, pass audience — otherwise a token minted for client A is accepted by client B.
Passing several expected audiences requires the token to carry all of them, not any one. To accept one of many, verify once per candidate.
Returns
Hash - The payload if valid, or { "error": true, "message": String } if invalid
# 2-arg form: HMAC only.
result = jwt_verify(token, getenv("JWT_SECRET"))
if has_key(result, "error")
println("Invalid token: " + result["message"])
else
println("User: " + result["sub"])
println("Role: " + result["role"])
end
# Asymmetric verification: pin the algorithm explicitly.
result = jwt_verify(token, "", { "algorithm": "RS256", "key": rsa_public_pem })
# Verifying an OIDC id_token: check who issued it and who it was meant for.
claims = jwt_verify(id_token, "", {
"algorithm": "RS256",
"key": provider_public_pem,
"issuer": "https://op.example",
"audience": getenv("OIDC_CLIENT_ID")
})
jwt_decode_unsafe(token)
Decode a JWT without verifying signature or expiration. The result is wrapped as {unverified: true, claims: {...}} so it cannot be confused with a verified jwt_verify response. Never trust these claims for authentication — use jwt_verify(token, secret) for that.
The previous jwt_decode(token) returned the same shape as jwt_verify, which made claims["sub"] a silent auth bypass. It was removed in SEC-029; calling it raises a migration error.
Parameters
token : String - The JWT token to decode
Returns
Hash — {unverified: true, claims: {...}} on success, {error: true, message: ...} on a malformed token
# Inspection only — DO NOT use for auth
let result = jwt_decode_unsafe(token)
println(result["claims"]["sub"])
Common Patterns
Authentication Flow
# Login endpoint
def login(email: String, password: String) -> Hash
user = User.find_by_email(email)
if !user || !argon2_verify(password, user["password_hash"])
return { "error": "Invalid credentials" }
end
token = jwt_sign(
{ "sub": str(user["id"]), "role": user["role"] },
getenv("JWT_SECRET"),
{ "expires_in": 86400 } # 24 hours
)
{ "token": token }
end
# Protected endpoint middleware
def authenticate(req: Hash) -> Hash?
auth_header = req["headers"]["Authorization"] ?? ""
if !contains(auth_header, "Bearer ")
return null
end
token = substring(auth_header, 7)
result = jwt_verify(token, getenv("JWT_SECRET"))
if has_key(result, "error")
return null
}
result
end