SQLITE_NOTICE(539): recovered 2 pages from /srv/fossil/jwt.fossil-journal
Examples
All examples on this page were run against the package as they stand here.
Signing a token and verifying it again:
package require jwt
set header {{"alg":"HS256","typ":"JWT"}}
set payload {{"sub":"alice","name":"Alice Müller"}}
set secret 0123456789abcdef0123456789abcdef
set token [::jwt::sign $header $payload $secret]
::jwt::verify $token $secret
# -> true
The header must carry alg, there is no default, and the secret has a lower
bound: 32 bytes for HS256, 48 for HS384, 64 for HS512.
Building a payload with an expiry time. The claims are JSON text, so either substitute the value or let rl_json build it:
set exp [expr {[clock seconds] + 3600}]
# plain Tcl
set payload [subst {{"sub":"alice","exp":$exp}}]
# or with rl_json, which quotes and types the value for you
package require rl_json
set payload [rl_json::json template {{"sub":"alice","exp":"~N:exp"}}]
set token [::jwt::sign $header $payload $secret]
Checking the expiry. Verification is signature-only unless -claims is
given, so an expired token verifies as true without it. That is deliberate and
it is the one place where the decision is left to the caller:
set exp [expr {[clock seconds] - 60}]
set old [::jwt::sign $header [subst {{"sub":"alice","exp":$exp}}] $secret]
::jwt::verify $old $secret
# -> true signature is fine, exp was not looked at
::jwt::verify $old $secret -claims
# -> false expired one minute ago
::jwt::verify $old $secret -claims -leeway 120
# -> true within the tolerance for clock skew
-leeway takes effect together with -claims only. An invalid value is an
error rather than a silent zero, so a typo cannot switch the check off.
Finding out why a token was rejected. With -json the result is an rl_json
document; together with -claims it carries reason:
set res [::jwt::verify $old $secret -claims -json]
rl_json::json get $res verify
# -> 0
rl_json::json get $res reason
# -> expired
set claims [rl_json::json get $res payload]
rl_json::json get $claims sub
# -> alice
reason is one of ok, malformed, alg, crit, signature, notbefore,
expired or payload, so a forged token can be told apart from one that
merely ran out of time. header and payload hold raw, re-parsable JSON text,
which is why reading a claim takes the two steps shown above.
Restricting the algorithm. A token names its own algorithm in the header, so a verifier that accepts whatever it finds there lets the sender choose. Name what you accept:
set key [string repeat k 64]
set token [::jwt::sign {{"alg":"HS512","typ":"JWT"}} {{"sub":"alice"}} $key]
::jwt::verify $token $key -alg HS256
# -> false the header asks for HS512, which was not accepted
::jwt::verify $token $key -alg HS512
# -> true
::jwt::verify $token $key -alg {HS256 HS512}
# -> true a list works as well
Verifying a token from an OpenID Connect provider. The client secret is chosen by the other side and used at whatever length it has - 64 characters is what the Nextcloud app OIDC Identity Provider generates:
set clientSecret $secretFromTheProvider
set res [::jwt::verify $idToken $clientSecret -claims -alg HS256 -json]
if {[rl_json::json get $res verify]} {
set claims [rl_json::json get $res payload]
puts "issuer [rl_json::json get $claims iss]"
puts "subject [rl_json::json get $claims sub]"
} else {
puts "rejected: [rl_json::json get $res reason]"
}
Do not pad, hash or shorten such a secret beforehand - any of that produces a different tag. It goes in unchanged, the RFC 2104 key preparation happens inside nacl.
HS384 and HS512 work the same way, only the header and the minimum key length differ:
::jwt::sign {{"alg":"HS384","typ":"JWT"}} $payload [string repeat k 48]
::jwt::sign {{"alg":"HS512","typ":"JWT"}} $payload [string repeat k 64]
Errors versus rejected tokens. A bad token makes verify return false, a
mistake on your side raises:
catch {::jwt::sign {{"typ":"JWT"}} $payload $secret} err
# jwt::sign: header has no alg (RFC 7515 section 4.1.1 requires it)
catch {::jwt::sign $header $payload kurz} err
# jwt::sign: HS256 requires a key of at least 32 bytes (RFC 7518 section 3.2), got 4
::jwt::verify garbage.not.atoken $secret
# -> false malformed input is rejected, not raised
Generating a secret of the right size, and putting it somewhere text-safe:
package require nacl
set key [nacl::randombytes 32]
set text [::jwt::base64url_encode $key]
# 43 characters, no padding, safe in a URL or a config file
set key [::jwt::base64url_decode $text]
base64url_encode and base64url_decode are public for exactly this purpose -
keys, salts and binary blobs. They do no character conversion, so they are safe
for arbitrary bytes.