Verifying in other languages
Complete verifiers for identity and data event tokens, with the same rules and refusal codes as the Node SDK.
For game servers that do not run JavaScript. The Node SDK
(@uloggd/arcade-sdk/server) is the reference: a verifier in another language
is right when it accepts the tokens the SDK accepts and refuses the others
with the same code. The rules for an identity token are in
Verify it in any language.
This page publishes a complete verifier in Python, and adds data event tokens,
the refusal codes in the order the SDK gives them, and what to check when you
use a JWT library.
Before you copy
Each file is tested against tokens signed by uloggd's own code, with every refusal the Node SDK makes. The code on this page is MIT licensed, like the SDK: copy it into your server.
- Fetch the keys only from
https://uloggd.com/api/v1/arcade/jwks, or pin the current public keys and follow the rotation schedule. - Keep the key set in a cache in the process, shared by every request, as the SDK does: for at most 5 minutes, whatever the response headers say.
- Accept the mock's tokens (
iss: "mock";allowMockin the SDK,allow_mockin Python) in local development only; a production verifier refuses them withmock_refused. - Two things may differ from the SDK without changing the answer to any
token: refusing a wrong configuration (an empty app id, a key set URL
outside
https://uloggd.com) when the verifier is made, rather than on each call; and giving the key set fetch a timeout of a few seconds, treating it asjwks_unavailable. - Remember each accepted
jtiand nonce for longer than a token lives, and refuse a repeat. Never deduplicate by the token's text.
The SDK's options, in each published verifier:
| Node | Python |
|---|---|
appId | ArcadeVerifier(app_id) |
nonce | nonce= on each call |
allowMock | allow_mock=True |
allowTestMode | allow_test_mode=False |
clockSkewSeconds | clock_skew_seconds |
jwksUrl | jwks_url |
issuer | issuer |
The Python verifier makes both differences the list above allows: a wrong
option raises ValueError when the verifier is made, and fetching the key set
times out after 5 seconds. It also refuses an identity token whose exp falls
after the year 9999 as malformed; uloggd never signs one.
Python
Python 3.10 or newer, with the cryptography package
(packages/arcade-sdk/examples/python/requirements.txt). The verifier blocks
while it fetches the keys: in an async server, call it from a sync endpoint or
through asyncio.to_thread.
# SPDX-License-Identifier: MIT
# Copyright (c) 2026 uloggd
"""Verifies uloggd Arcade identity tokens and data event tokens.
A copy-ready port of verifyIdentityToken and verifyEventToken from
@uloggd/arcade-sdk/server: the same rules, in the same order, with the same
refusal codes and the same JWKS cache. Python 3.10 or newer, and only the
`cryptography` package (requirements.txt). No JWT library: the format is
fixed, and a general-purpose library would accept more than uloggd ever
signs.
verifier = ArcadeVerifier("YOUR_APP_ID")
player = verifier.verify_identity_token(token, nonce=nonce)
event = verifier.verify_event_token(event_token)
Both raise ArcadeTokenError, whose .code is one of TOKEN_ERROR_CODES. One
verifier per process: it caches uloggd's keys, and it is safe to share
between threads.
"""
from __future__ import annotations
import base64
import http.client
import json
import math
import re
import threading
import time
import urllib.error
import urllib.parse
import urllib.request
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from typing import Callable, Optional
from cryptography.exceptions import InvalidSignature
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.asymmetric import ec
from cryptography.hazmat.primitives.asymmetric.utils import (
encode_dss_signature,
)
DEFAULT_JWKS_URL = "https://uloggd.com/api/v1/arcade/jwks"
IDENTITY_TOKEN_TYPE = "uloggd-arcade-id+jwt"
EVENT_TOKEN_TYPE = "uloggd-arcade-event+jwt"
DATA_EVENTS = (
"grant_revoked",
"grant_forgotten",
"nickname_changed",
"account_deleted",
)
TOKEN_ERROR_CODES = (
"malformed",
"unsupported_header",
"unknown_key",
"bad_signature",
"wrong_type",
"wrong_issuer",
"wrong_audience",
"expired",
"not_yet_valid",
"too_old",
"nonce_mismatch",
"mock_refused",
"test_refused",
"jwks_unavailable",
)
_MOCK_ISSUER = "mock"
_MOCK_KID_PREFIX = "mock-"
_IDENTITY_MAX_AGE = 300
_EVENT_MAX_AGE = 7 * 24 * 60 * 60
_DEFAULT_SKEW = 60
_MAX_SKEW = 300
_MAX_TOKEN_LENGTH = 8192
_MAX_JWKS_BYTES = 64 * 1024
_MAX_JWKS_KEYS = 20
# However long the JWKS answer says it may be cached.
_JWKS_MAX_AGE = 300.0
# An unknown kid refetches the JWKS at most this often.
_JWKS_REFETCH = 60.0
# After a failed fetch the JWKS is not asked again for this long: a forged
# token reaches the fetch (its header and iss are unsigned), so without a
# pause each one would cost a request while the JWKS is down.
_JWKS_RETRY = 10.0
_FETCH_TIMEOUT = 5.0
# Checked before any decoder runs: base64.b64decode drops characters outside
# the alphabet instead of refusing them.
_BASE64URL = re.compile(r"[A-Za-z0-9_-]*")
_COORDINATE = re.compile(r"[A-Za-z0-9_-]{43}")
_LOOPBACK_IPV4 = re.compile(r"127(?:\.(?:0|[1-9][0-9]{0,2})){3}")
# JavaScript's \s, spelled out: Python's own \s is a different set.
_SPACE = (
"[\t\n\v\f\r \u00a0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000"
"\ufeff]"
)
# The header is lowercased in ASCII first, so no Unicode case folding
# applies, and \Z because $ also matches before a final newline.
_NO_STORE = re.compile(
"(?:^|,)" + _SPACE + "*(?:no-store|no-cache)" + _SPACE + r"*(?:,|\Z)"
)
_MAX_AGE = re.compile(
"(?:^|,)" + _SPACE + "*max-age" + _SPACE + "*=" + _SPACE + '*"?([0-9]+)"?'
)
_EPOCH = datetime(1970, 1, 1, tzinfo=timezone.utc)
class ArcadeTokenError(Exception):
"""A refused token. `code` is one of TOKEN_ERROR_CODES."""
def __init__(self, code: str, message: Optional[str] = None) -> None:
super().__init__(message or code)
self.code = code
@dataclass(frozen=True)
class VerifiedIdentity:
# The player's id for your app alone.
subject: str
nickname: str
scopes: tuple[str, ...]
instance_id: str
# "live" or "test".
mode: str
# The token's jti: deduplicate by it (and the nonce), never by the
# token's text.
id: str
issued_at: datetime
expires_at: datetime
@dataclass(frozen=True)
class VerifiedEvent:
subject: str
event: str
# Present only with nickname_changed.
nickname: Optional[str]
id: str
issued_at: datetime
class _KeySet:
def __init__(self, keys, expires_at: float, attempted_at: float) -> None:
self.keys = keys
self.expires_at = expires_at
self.attempted_at = attempted_at
class _RefuseRedirects(urllib.request.HTTPRedirectHandler):
# A redirect is a failure: the answer stays a 3xx, never followed.
def redirect_request(self, req, fp, code, msg, headers, newurl):
return None
def _fail(code: str, message: Optional[str] = None):
raise ArcadeTokenError(code, message)
def _too_long(token: str) -> bool:
# The limit counts UTF-16 code units, as JavaScript's length does, so an
# astral character counts twice. Code points never outnumber them, so a
# token already over in code points is refused without encoding it.
return (
len(token) > _MAX_TOKEN_LENGTH
or len(token.encode("utf-16-le", "surrogatepass")) // 2 > _MAX_TOKEN_LENGTH
)
def _decode_base64url(text: str) -> bytes:
if _BASE64URL.fullmatch(text) is None or len(text) % 4 == 1:
_fail("malformed", "not base64url")
return base64.urlsafe_b64decode(text + "=" * (-len(text) % 4))
def _refuse_constant(name: str):
raise ValueError(f"{name} is not JSON")
def _loads(text: str):
# As JavaScript's JSON.parse: NaN and Infinity are not JSON, and every
# number is a double, however many digits it has.
return json.loads(text, parse_int=float, parse_constant=_refuse_constant)
def _decode_json(segment: str) -> dict:
raw = _decode_base64url(segment)
try:
# utf-8-sig drops one leading byte order mark, as TextDecoder does.
value = _loads(raw.decode("utf-8-sig"))
except (UnicodeDecodeError, ValueError, RecursionError):
_fail("malformed", "not JSON")
if not isinstance(value, dict):
_fail("malformed", "not a JSON object")
return value
def _whole(value) -> Optional[float]:
"""A JSON number with a whole value, or None. Booleans are not numbers."""
if isinstance(value, bool) or not isinstance(value, (int, float)):
return None
try:
number = float(value)
except OverflowError:
return None
if not math.isfinite(number) or not number.is_integer():
return None
return number
def _utc(seconds: float) -> datetime:
try:
return _EPOCH + timedelta(seconds=seconds)
except OverflowError:
# Python's datetime ends with the year 9999; uloggd never signs
# past it (an identity token lives 300 s).
_fail("malformed", "a time beyond the year 9999")
def _is_loopback(host: str) -> bool:
if host in ("localhost", "::1") or host.endswith(".localhost"):
return True
return _LOOPBACK_IPV4.fullmatch(host) is not None and all(
int(part) <= 255 for part in host.split(".")
)
def _trusted_origin(url: str, what: str) -> str:
"""https://uloggd.com, or a loopback origin for tests, as an origin."""
if not isinstance(url, str) or any(
ord(char) <= 0x20 or char in "\\\x7f" for char in url
):
raise ValueError(f"{what} must be on https://uloggd.com")
try:
parts = urllib.parse.urlsplit(url)
port = parts.port
except ValueError:
raise ValueError(f"{what} must be on https://uloggd.com") from None
scheme = parts.scheme.lower()
host = parts.hostname or ""
if parts.username is not None or parts.password is not None:
raise ValueError(f"{what} must not carry a user name or password")
default_port = {"http": 80, "https": 443}.get(scheme)
if scheme == "https" and host == "uloggd.com" and port in (None, 443):
return "https://uloggd.com"
if default_port is not None and _is_loopback(host):
shown = "[::1]" if host == "::1" else host
suffix = "" if port in (None, default_port) else f":{port}"
return f"{scheme}://{shown}{suffix}"
raise ValueError(f"{what} must be on https://uloggd.com")
def _max_age(cache_control: str) -> float:
if not cache_control:
return _JWKS_MAX_AGE
lowered = cache_control.translate(
{code: code + 32 for code in range(ord("A"), ord("Z") + 1)}
)
if _NO_STORE.search(lowered):
return 0.0
match = _MAX_AGE.search(lowered)
return min(float(match.group(1)), _JWKS_MAX_AGE) if match else _JWKS_MAX_AGE
def _point(raw: bytes):
try:
return ec.EllipticCurvePublicKey.from_encoded_point(
ec.SECP256R1(), raw
)
except ValueError:
return None
def _import_jwk(jwk: dict):
x, y = jwk.get("x"), jwk.get("y")
if (
jwk.get("kty") != "EC"
or jwk.get("crv") != "P-256"
or not isinstance(x, str)
or not isinstance(y, str)
or _COORDINATE.fullmatch(x) is None
or _COORDINATE.fullmatch(y) is None
or ("alg" in jwk and jwk["alg"] != "ES256")
or ("use" in jwk and jwk["use"] != "sig")
):
return None
# Only the public coordinates are read, whatever else the entry holds.
return _point(b"\x04" + _decode_base64url(x) + _decode_base64url(y))
def _mock_key(kid: str):
# The mock names its public key in the kid: "mock-" and the base64url
# of the uncompressed point. A kid that is not base64url is malformed,
# as in Node.
raw = (
_decode_base64url(kid[len(_MOCK_KID_PREFIX):])
if kid.startswith(_MOCK_KID_PREFIX)
else None
)
if raw is None or len(raw) != 65 or raw[0] != 4:
_fail("unknown_key", "not a mock key")
key = _point(raw)
if key is None:
_fail("unknown_key", "not a mock key")
return key
class ArcadeVerifier:
"""Verifies uloggd's tokens for one app. Make one, and share it.
jwks_url and issuer accept only https://uloggd.com, or a loopback
origin for tests; leave them out. allow_mock accepts tokens from the
SDK's mock (iss "mock"): local development only. allow_test_mode=False
refuses tokens from the owner's test mode with test_refused. A bad
argument raises ValueError here, rather than on each token.
"""
def __init__(
self,
app_id: str,
*,
jwks_url: str = DEFAULT_JWKS_URL,
issuer: Optional[str] = None,
allow_mock: bool = False,
allow_test_mode: bool = True,
clock_skew_seconds: float = _DEFAULT_SKEW,
clock: Callable[[], float] = time.time,
) -> None:
if not isinstance(app_id, str) or app_id == "":
raise ValueError("app_id is required")
if (
isinstance(clock_skew_seconds, bool)
or not isinstance(clock_skew_seconds, (int, float))
or not 0 <= clock_skew_seconds <= _MAX_SKEW
):
raise ValueError(
f"clock_skew_seconds must be between 0 and {_MAX_SKEW}"
)
jwks_origin = _trusted_origin(jwks_url, "jwks_url")
self._app_id = app_id
self._jwks_url = jwks_url
self._issuer = (
jwks_origin if issuer is None else _trusted_origin(issuer, "issuer")
)
self._allow_mock = allow_mock is True
self._allow_test_mode = allow_test_mode is not False
self._skew = float(clock_skew_seconds)
self._clock = clock
self._opener = urllib.request.build_opener(_RefuseRedirects)
self._state = threading.Lock()
self._fetching = threading.Lock()
self._set: Optional[_KeySet] = None
self._failed_at: Optional[float] = None
# Counts finished fetches, so a thread that waited for another's
# fetch takes its outcome instead of fetching again.
self._fetches = 0
self._last_set: Optional[_KeySet] = None
self._last_error: Optional[str] = None
def verify_identity_token(self, token, *, nonce) -> VerifiedIdentity:
"""Verifies an identity token from getIdentityToken.
Accept each nonce once, bind it to the session that asked for it,
and deduplicate by the returned id.
"""
claims = self._verify(
token, IDENTITY_TOKEN_TYPE, self._skew, _IDENTITY_MAX_AGE, True
)
scope = claims.get("scope")
if (
not isinstance(claims.get("nickname"), str)
or not isinstance(scope, str)
or "identify" not in scope.split(" ")
or not isinstance(claims.get("iid"), str)
or claims.get("mode") not in ("live", "test")
):
_fail(
"malformed",
"nickname, scope with identify, iid and mode are required",
)
claimed = claims.get("nonce")
if (
not isinstance(nonce, str)
or nonce == ""
or not isinstance(claimed, str)
or claimed != nonce
):
_fail("nonce_mismatch")
if claims["mode"] == "test" and not self._allow_test_mode:
_fail("test_refused", "a token from the owner's test mode")
return VerifiedIdentity(
subject=claims["sub"],
nickname=claims["nickname"],
scopes=tuple(part for part in scope.split(" ") if part),
instance_id=claims["iid"],
mode=claims["mode"],
id=claims["jti"],
issued_at=_utc(claims["iat"]),
expires_at=_utc(claims["exp"]),
)
def verify_event_token(self, token) -> VerifiedEvent:
"""Verifies a data event token posted as {"event_token": "<jwt>"}.
Deduplicate by the returned id; act on the event within 15 days.
"""
claims = self._verify(
token, EVENT_TOKEN_TYPE, _DEFAULT_SKEW, _EVENT_MAX_AGE, False
)
event = claims.get("event")
if not isinstance(event, str) or event not in DATA_EVENTS:
_fail("malformed", "unknown event")
renamed = event == "nickname_changed"
if (
not isinstance(claims.get("nickname"), str)
if renamed
else "nickname" in claims
):
_fail("malformed", "nickname comes only with nickname_changed")
return VerifiedEvent(
subject=claims["sub"],
event=event,
nickname=claims["nickname"] if renamed else None,
id=claims["jti"],
issued_at=_utc(claims["iat"]),
)
def _verify(self, token, typ, skew, max_age, allow_mock) -> dict:
if not isinstance(token, str) or _too_long(token):
_fail("malformed", "not a token")
parts = token.split(".")
if len(parts) != 3:
_fail("malformed", "a JWT has three parts")
header = _decode_json(parts[0])
if (
set(header) != {"alg", "kid", "typ"}
or header["alg"] != "ES256"
or not isinstance(header["kid"], str)
):
_fail(
"unsupported_header",
"the header must be exactly {alg: ES256, typ, kid}",
)
if not isinstance(header["typ"], str) or header["typ"] != typ:
_fail("wrong_type", f"typ must be {typ}")
claims = _decode_json(parts[1])
signature = _decode_base64url(parts[2])
if len(signature) != 64:
_fail("bad_signature", "the signature must be raw r||s (64 bytes)")
iss = claims.get("iss")
if isinstance(iss, str) and iss == _MOCK_ISSUER:
if not (allow_mock and self._allow_mock):
_fail("mock_refused", "a token from the SDK's mock")
key = _mock_key(header["kid"])
else:
if not isinstance(iss, str) or iss != self._issuer:
_fail("wrong_issuer", f"iss must be {self._issuer}")
key = self._jwks_key(header["kid"])
der = encode_dss_signature(
int.from_bytes(signature[:32], "big"),
int.from_bytes(signature[32:], "big"),
)
try:
key.verify(
der,
f"{parts[0]}.{parts[1]}".encode("ascii"),
ec.ECDSA(hashes.SHA256()),
)
except InvalidSignature:
_fail("bad_signature")
aud = claims.get("aud")
if not isinstance(aud, str) or aud != self._app_id:
_fail("wrong_audience", "aud is not this app")
iat, exp = _whole(claims.get("iat")), _whole(claims.get("exp"))
if (
iat is None
or exp is None
or exp <= iat
or not isinstance(claims.get("sub"), str)
or not isinstance(claims.get("jti"), str)
):
_fail("malformed", "iat, exp, sub and jti are required")
now = self._clock()
if iat > now + skew:
_fail("not_yet_valid")
if now >= exp + skew:
_fail("expired")
if now - iat > max_age + skew:
_fail("too_old")
return {**claims, "iat": iat, "exp": exp}
def _jwks_key(self, kid: str):
with self._state:
current = self._set
now = self._clock()
expired = current is None or now >= current.expires_at
if expired:
# An expired set is never used, even when the refetch
# fails: a dropped key must stop verifying.
self._set = None
if (
self._failed_at is not None
and now - self._failed_at < _JWKS_RETRY
):
_fail("jwks_unavailable", "the JWKS failed moments ago")
if expired:
current = self._refresh()
key = current.keys.get(kid)
if key is None:
with self._state:
now = self._clock()
# A new kid after a rotation: refetch, but at most once a
# minute, so made-up kids cannot become a flood of requests.
refetch = now - current.attempted_at >= _JWKS_REFETCH
if refetch:
current.attempted_at = now
if refetch:
current = self._refresh()
key = current.keys.get(kid)
if key is None:
_fail("unknown_key", f"no key {kid} in the JWKS")
return key
def _refresh(self) -> _KeySet:
"""One fetch at a time; threads that wait share its outcome."""
with self._state:
seen = self._fetches
with self._fetching:
with self._state:
if self._fetches != seen:
if self._last_error is not None:
_fail("jwks_unavailable", self._last_error)
return self._last_set
try:
fetched = self._fetch()
except ArcadeTokenError as error:
with self._state:
self._failed_at = self._clock()
self._fetches += 1
self._last_error = str(error)
raise
with self._state:
self._failed_at = None
self._set = fetched
self._fetches += 1
self._last_set = fetched
self._last_error = None
return fetched
def _fetch(self) -> _KeySet:
attempted_at = self._clock()
request = urllib.request.Request(
self._jwks_url, headers={"Accept": "application/json"}
)
try:
with self._opener.open(request, timeout=_FETCH_TIMEOUT) as answer:
status = answer.status
cache_control = ", ".join(
answer.headers.get_all("Cache-Control") or []
)
body = answer.read(_MAX_JWKS_BYTES + 1)
except urllib.error.HTTPError as error:
error.close()
_fail("jwks_unavailable", f"the JWKS answered {error.code}")
except (OSError, ValueError, http.client.HTTPException):
_fail("jwks_unavailable", "the JWKS could not be fetched")
if status != 200 or len(body) > _MAX_JWKS_BYTES:
_fail("jwks_unavailable", f"the JWKS answered {status}")
try:
parsed = _loads(body.decode("utf-8-sig", errors="replace"))
except (ValueError, RecursionError):
_fail("jwks_unavailable", "the JWKS is not JSON")
if not isinstance(parsed, dict) or not isinstance(
parsed.get("keys"), list
):
_fail("jwks_unavailable", "the JWKS has no keys array")
keys = {}
for jwk in parsed["keys"][:_MAX_JWKS_KEYS]:
if (
not isinstance(jwk, dict)
or not isinstance(jwk.get("kid"), str)
or jwk["kid"] in keys
):
continue
key = _import_jwk(jwk)
if key is not None:
keys[jwk["kid"]] = key
return _KeySet(
keys, attempted_at + _max_age(cache_control), attempted_at
)
@app.post("/session")
def session():
nonce = take_nonce(request)
body = request.get_json(silent=True)
token = body.get("token") if isinstance(body, dict) else None
try:
player = verifier.verify_identity_token(token, nonce=nonce)
except ArcadeTokenError as error:
return jsonify(error=error.code), 401
# Deduplicate by the token's id, never by its text.
if not remember_once(player.id):
return "", 409
return jsonify(session=open_session(player.subject, player.nickname))Data events
A data event token goes through the same checks as an identity token, with the same key set (see The event token), with these differences:
typisuloggd-arcade-event+jwt. An identity token sent as an event iswrong_type, and so is the reverse.- There is no
nonce,scope,iidormodeto check. eventisgrant_revoked,grant_forgotten,nickname_changedoraccount_deleted. Any other value ismalformed.nicknamecomes, as a string, only withnickname_changed, and is absent from every other event. Anything else ismalformed.- The clock skew allowed is always 60 seconds, and
iatmay be up to 7 days old; after that,too_old. - There is no option for the mock: an event token with
issmockis alwaysmock_refused.
@app.post("/uloggd-events")
def uloggd_events():
body = request.get_json(silent=True)
token = body.get("event_token") if isinstance(body, dict) else None
try:
event = verifier.verify_event_token(token)
except ArcadeTokenError:
return "", 400
# The same event can arrive twice: the job deduplicates by event.id.
enqueue(event)
return "", 204Answer with a 2xx, such as 204, as soon as the token verifies and the
event is in your queue, and with any other status to a refusal, such as
400. uloggd tries again later (see
Delivery), which saves an
event refused with jwks_unavailable while the key set was not answering. The
jti is the event's id and stays the same on every retry: deduplicate by it.
Refuse bodies over 8 KiB.
Refusal codes
The same 14 codes as the SDK's ArcadeTokenError:
| Code | When |
|---|---|
malformed | The shape: the token is not a string of at most 8192 characters in three base64url segments, the header or the payload is not a JSON object, or, in a mock token, the text after mock- in kid is not base64url. Or the members: a required one is missing or of the wrong type, exp is not after iat, scope does not include identify, mode is neither live nor test, or event and nickname do not match. |
unsupported_header | The header does not have exactly alg, typ and kid, alg is not ES256, or kid is not a string. |
wrong_type | typ is not the expected type: uloggd-arcade-id+jwt for identity, uloggd-arcade-event+jwt for events. |
bad_signature | The signature is not 64 bytes, or does not verify with the key. |
mock_refused | iss is mock, and the verifier does not accept the mock. |
wrong_issuer | iss is not https://uloggd.com. |
jwks_unavailable | The key set could not be fetched or read, a fetch failed less than 10 seconds ago, or the configured key set URL or issuer is outside https://uloggd.com (and a loopback origin, in tests). |
unknown_key | kid is not in the set, even after the one refetch a minute allows. In a mock token, kid does not start with mock-, or what follows is not a P-256 public key. |
wrong_audience | aud is not your app id. |
not_yet_valid | iat is in the future by more than the clock skew. |
expired | exp plus the clock skew has passed. |
too_old | iat is older than the token's lifetime plus the clock skew: 300 seconds for identity, 7 days for events. |
nonce_mismatch | Identity only: the nonce is empty or not the one your server issued. |
test_refused | Identity only: mode is test, and the verifier refuses test mode. |
A token with more than one fault gets the code of the first check that fails. The SDK checks in these steps, in order:
- the shape and the header (
malformed,unsupported_header,wrong_type); - the payload and the signature's length (
malformed,bad_signature); - the configuration, the issuer and the key (
jwks_unavailable,mock_refused,wrong_issuer,unknown_key); - the signature (
bad_signature); aud(wrong_audience);- the common members and the time (
malformed,not_yet_valid,expired,too_old); - in an identity token, its own members, the nonce and the mode
(
malformed,nonce_mismatch,test_refused); in an event token,eventandnickname(malformed).
With a JWT library
A general-purpose JOSE or JWT library accepts more forms than uloggd signs. If you use one, check each point below in your own code:
- Pin
ES256. PassES256as the only accepted algorithm. Never let the token'salgchoose, and refusenoneand HMAC. - Read the header yourself. Decode the first segment before calling the
library and require exactly
alg,typandkid(unsupported_header). A library may ignore members it does not know, or followjku,jwkorx5uto another key. - Compare
typ. Exactlyuloggd-arcade-id+jwtoruloggd-arcade-event+jwt, as text (wrong_type). - Compare
audas one string.audis a string equal to your app id (wrong_audience). Do not accept a list that contains the id: uloggd never signs one. - Refetch on an unknown
kidat most once a minute. Thekidcomes in a header nobody signed, so without that limit every made-up token would turn into a fetch. - Refuse a
kidthat is not in the set, rather than trying every key (unknown_key). Trying them all would accept a token the SDK refuses, and would make every forged token cost one verification per key. - Check the age of
iat.expis not enough:iatis at most 300 seconds old in an identity token and 7 days old in an event token, plus the clock skew (too_old), and not in the future by more than the skew (not_yet_valid). - Use raw 64-byte signatures.
rthens, 32 bytes each. If your platform's cryptography wants DER, convert before verifying, and refuse a signature that arrives in DER or at any other length (bad_signature).
After the library, check the rest: the members, the nonce, the scope, the
mode and replays, as in
Verify it in any language.