jwt
Create and check signed JSON Web Tokens for logins and API access, with expiry, issuer and audience checks.
import "std:jwt"A JSON Web Token (JWT) is a small signed note: "this is user ada, and it is good for an hour". A server hands one out at login, the client sends it back with each request, and the server trusts what is inside once the signature checks out, with no session table to look up. std:jwt creates and checks tokens signed with a shared secret (HS256, HS384 or HS512).
Make and check a token#
jwt.create(claims, secret) signs an object into a token string. jwt.check(token, secret) verifies it and gives the claims back:
import "std:jwt"
string secret = "a-secret-that-is-at-least-32-bytes!"
string token = jwt.create({sub: "ada", role: "admin"}, secret).unwrap()
log token.split(".").len
auto claims = jwt.check(token, secret).unwrap()
log claims["sub"] claims["role"]
log claims["iat"] != nullA token is three dot-separated parts: header, claims, signature. create adds iat, when it was issued.
Tokens are signed, not secret#
Anyone can read what is inside a token. The signature only stops them changing it. jwt.decode reads a token without checking it, which is fine for showing a name in a UI, but never trust what it returns for a decision:
import "std:jwt"
string secret = "a-secret-that-is-at-least-32-bytes!"
string token = jwt.create({sub: "ada"}, secret).unwrap()
auto parts = jwt.decode(token).unwrap()
log parts.header
log parts.payload["sub"]So never put passwords or private data in the claims.
Bad tokens#
check fails for a changed token, a wrong secret and a token that isn't one. The error kind tells you which:
import "std:jwt"
string secret = "a-secret-that-is-at-least-32-bytes!"
string other = "another-secret-that-is-32-bytes-long"
string token = jwt.create({sub: "ada"}, secret).unwrap()
log jwt.check(token, other).unwrapErr().kind
log jwt.check(token ++ "x", secret).isErr()
log jwt.check("not.a.token", secret).unwrapErr().kind
switch jwt.check(token, secret) (
case ok(claims)
log "welcome" claims["sub"]
case err(e)
log "refused:" e.kind
)PermissionError means the signature or the audience or issuer is wrong. ParseError means the text isn't a token. Treat all failures the same toward the client: refuse the request.
A client sends a token whose claims say role: "admin", but you signed it with a different secret. What does jwt.check do?
Expiry#
Give a token a lifetime with expiresIn, in milliseconds. After that, check fails with an ExpiredError, and isExpired says so without needing the secret:
import "std:jwt"
import "std:thread"
string secret = "a-secret-that-is-at-least-32-bytes!"
string token = jwt.create({sub: "ada"}, secret, {expiresIn: 1000}).unwrap()
log jwt.isExpired(token)
thread.sleep(2100)
log jwt.isExpired(token)
log jwt.check(token, secret).unwrapErr().kindTry an expiresIn of 60000 and the sleep no longer matters. For a login token an hour (3600000) is typical. Short lifetimes limit what a stolen token can do.
Renewing#
jwt.renew(token, secret, options) checks a token and returns a new one with the same claims and a fresh lifetime:
import "std:jwt"
string secret = "a-secret-that-is-at-least-32-bytes!"
string first = jwt.create({sub: "ada"}, secret, {expiresIn: 60000}).unwrap()
string second = jwt.renew(first, secret, {expiresIn: 3600000}).unwrap()
log jwt.check(second, secret).unwrap()["sub"]Issuer and audience#
iss says who made the token and aud who it is for. Ask check to require them, so a token for one service isn't accepted by another:
import "std:jwt"
string secret = "a-secret-that-is-at-least-32-bytes!"
auto claims = {sub: "ada", iss: "auth.example", aud: "billing"}
string token = jwt.create(claims, secret).unwrap()
log jwt.check(token, secret, {issuer: "auth.example", audience: "billing"}).isOk()
log jwt.check(token, secret, {audience: "search"}).isErr()
log jwt.check(token, secret, {issuer: "someone.else"}).isErr()Protect a route#
Put it together with serve: a middleware reads the bearer token, checks it, and passes the claims on:
import "std:jwt"
import "std:serve"
import "std:requests"
string secret = "a-secret-that-is-at-least-32-bytes!"
auto app = serve.new()
app.use(def(*serve.Context c) (
string token = c.bearer() ?? ""
switch jwt.check(token, secret) (
case ok(claims)
c.set("user", claims["sub"])
c.next()
case err(e)
c.unauthorized("invalid token")
)
))
app.get("/me", def(*serve.Context c) (
c.text(200, "hello " ++ c.get("user"))
))
app.start("127.0.0.1:8775").unwrap()
string good = jwt.create({sub: "ada"}, secret).unwrap()
auto headers = {Authorization: "Bearer " ++ good}
auto ok = requests.get("http://127.0.0.1:8775/me", {headers: headers}).unwrap()
log ok.status ok.body
log requests.get("http://127.0.0.1:8775/me").unwrap().status
app.stop().unwrap()The request with the token prints 200 hello ada, and the one without prints 401.
Admin only
Write adminName(token, secret). It returns the token's sub when the token is valid and its role claim is "admin", and "denied" otherwise.
ada denied denied
API reference#
11 members, generated from the std:jwt source of OSL 9.3.0.
Functions
jwt.check(string token, byte[] secret, object? options) result<object, error>check verifies a token signed with secret and returns its claims. It accepts HS256, and HS384 and HS512 when secret is long enough for them (48 and 64 bytes), never an unsigned token, and rejects a token past its exp time or before its nbf time. Options: algorithm, the one algorithm to accept ("HS256", "HS384" or "HS512"); leeway, the clock difference to allow in milliseconds (default 0); audience, a string or an array of strings, one of which the token's aud claim must hold; and issuer, a string or an array of strings, one of which its iss claim must be. Fails with ParseError for a malformed token, ExpiredError for an expired token, PermissionError for a wrong signature, an unsigned token, one not valid yet, another algorithm than the option names or one that needs a longer secret, or the wrong audience or issuer, UnsupportedError for an algorithm other than HS256, HS384 and HS512, and ValueError for a secret under 32 bytes, or shorter than the algorithm option needs.
jwt.create(object claims, byte[] secret, object? options) result<string, error>create signs claims into a token with secret. It adds iat, the time it was issued in unix seconds, unless claims has one, and leaves claims itself unchanged. Options: expiresIn, the token's lifetime in milliseconds, which sets exp; and algorithm, "HS256" (the default), "HS384" or "HS512". secret is at least as long as the algorithm's hash: 32 bytes for HS256, 48 for HS384 and 64 for HS512, such as crypto.randomHex(32); a shorter one fails with ValueError. Throws ValueError for a bad option or claims that can't be written as JSON.
jwt.decode(string token) result<{error: string? (deprecated), header: object, payload: object}, error>decode reads a token's header and claims without checking its signature, as {header, payload}; the deprecated key error is always null. Never trust the result: use jwt.check for that. Fails with ParseError for a malformed token.
jwt.encode(any header, any payload, any secret) stringDeprecated. Deprecated: use jwt.create(claims, secret, options) instead, which writes a header that matches the signature.
encode signs a raw JSON header and payload with HS256, whatever alg the header names.
jwt.getClaim(any token, any claim) anyDeprecated. Deprecated: use jwt.check(token, secret)[name] for a claim you can trust, or jwt.decode(token).payload[name].
getClaim returns one claim of a token without checking its signature, or null when it is missing or the token is malformed.
jwt.isExpired(string token) booleanisExpired reports whether a token's exp time has passed, without checking its signature. A token without exp never expires; a malformed token, or one whose exp isn't a number, counts as expired.
jwt.refresh(any token, any secret, any expiresIn) stringDeprecated. Deprecated: use jwt.renew(token, secret, {expiresIn: ms}) instead; note that expiresIn there is in milliseconds.
refresh verifies a token like jwt.verify and signs its claims again, expiring expiresIn seconds from now, or returns "" when it isn't valid.
jwt.renew(string token, byte[] secret, object? options) result<string, error>renew checks a token like jwt.check and returns a new one with the same claims, a fresh iat and, with the option expiresIn (ms), a new lifetime; without it, the old lifetime from iat to exp carries over. Option: algorithm, by default the old token's. Fails like jwt.check, and with ValueError when secret is too short for the new algorithm.
jwt.sign(object claims, any secret, any ...expiresIn) stringDeprecated. Deprecated: use jwt.create(claims, secret, {expiresIn: ms}) instead; note that expiresIn there is in milliseconds.
sign signs claims with HS256, adding exp and iat when expiresIn, in seconds, is above 0. Returns "" when claims can't be written as JSON.
jwt.signWithExpiry(object claims, any secret, any expiresIn) stringDeprecated. Deprecated: use jwt.create(claims, secret, {expiresIn: ms}) instead; note that expiresIn there is in milliseconds.
signWithExpiry signs a copy of claims with iat and exp added, expiring expiresIn seconds from now, rounded up to the next whole second.
jwt.verify(any token, any secret) objectDeprecated. Deprecated: use jwt.check(token, secret) instead, which returns the claims and fails with a typed error.
verify checks an HS256 token and returns {valid: true, header, payload}, or {valid: false, error} with error one of "token_too_large", "invalid_token_format", "invalid_signature", "invalid_payload", "invalid_exp", "token_expired", "invalid_header" or "unsupported_algorithm".