Packages

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:

main.osl
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"] != null
Click the code to edit it

A 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:

main.osl
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"]
Click the code to edit it

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:

main.osl
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
)
Click the code to edit it

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.

Quick check

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:

main.osl
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().kind
Click the code to edit it

Try 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:

main.osl
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"]
Click the code to edit it

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:

main.osl
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()
Click the code to edit it

Protect a route#

Put it together with serve: a middleware reads the bearer token, checks it, and passes the claims on:

main.osl
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()
Click the code to edit it

The request with the token prints 200 hello ada, and the one without prints 401.

Your turn

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.

main.osl
Ctrl Enter runs
Expected output
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) string

Deprecated. 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) any

Deprecated. 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) boolean

isExpired 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) string

Deprecated. 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) string

Deprecated. 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) string

Deprecated. 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) object

Deprecated. 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".