Ulises LicensesUlises Licenses
FeaturesPricingAPIAnnouncements
Solutions
Java & Kotlin LicensingMinecraft Plugin LicensingChoosing LicensingBuiltByBit IntegrationPolymart Integration
Discord
Dashboard
  • Overview
Using the SDK
  • Installation
  • Plugin Integration
  • Complete Example
  • API Methods
  • Connection Management
  • Auto-Update
  • Best Practices
Using the Endpoint
  • Endpoint Overview
  • Validate a License
  • Verify the Response
  • Endpoint Best Practices
  1. Docs
  2. /
  3. Verify the Response

Verifying the Signed Token

Every 200 response carries a compact JWS. Verifying it is what turns a JSON body you were handed into proof that Ulises Licenses produced it.

Why verify at all

HTTPS is not the end of the story. It protects the connection from the network, not from the machine on the other end, and a server owner has every incentive to patch your plugin if patching it is easy.

  • A proxy can sit in the middle: HTTPS protects you from the network, not from the machine in front of it. A corporate proxy, an antivirus TLS inspector or a managed device configuration can install its own root certificate and terminate the connection. Once it does, it can rewrite the response body and the token inside it. A signature check catches that.
  • Your own client is modifiable: Anyone running a server can patch the jar, stub out the check, or point your plugin at a local server. That is what obfuscation and native compilation are for. A signature check means a patch has to also forge a valid token, which requires our private key.
  • A captured response can be replayed: A signed response is a real document. Sending a nonce makes it single use, and the exp claim bounds how long it stays useful. Together they close the window where a captured 200 body is enough to start your plugin.

The token

Three base64url segments separated by dots: the protected header, the payload, and the signature. The signed input is the ASCII bytes of header.payload, exactly as they appear in the token.

protected header
JSON
1{2  "alg": "ES256",3  "typ": "ULS-LIC",4  "kid": "2026-10"5}
payload
JSON
1{2  "v": 2,3  "iss": "https://www.uliseslicenses.xyz",4  "aud": "65f1c2a4b9d84e37a0c5119f",5  "jti": "b3f1c8d2a9e04756",6  "iat": 1767225600,7  "nbf": 1767225300,8  "exp": 1767240000,9  "hwid": "9A2B7C1D-44E5-4F60-8B9A-0C1D2E3F4A5B",10  "st": "active",11  "data": {12    "product": { "id": "...", "name": "...", "description": "..." },13    "license": { "discordId": "...", "status": "active", "expiresAt": "..." },14    "updateInfo": { "available": true, "version": "1.2.3", "jarUrl": null }15  }16}
ClaimMeaning
vToken format version. Currently 2. Reject anything you do not recognise.
issIssuer. Always https://www.uliseslicenses.xyz.
audThe product id this token was issued for. Must equal the product id you sent.
jtiThe nonce for this request. Equal to the nonce in the response body.
iatIssued at, seconds since the Unix epoch.
nbfNot valid before. Set to iat minus 300 seconds of clock skew allowance.
expExpires at, 4 hours after iat. Re-check well before this.
hwidThe normalized hardware id the license was checked against, or null when none was sent.
stLicense status at issue time, normally active.
dataThe same data tree the response body carries.

The signing key

Ship the public key with your plugin and key it by kid. Never fetch a key from a URL the response told you to fetch it from, because anything that can rewrite the body can rewrite that URL.

keys.json
JSON
1{2  "2026-10": "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE5v07+GZ7jOdgk0ImQ7ESxjClbo+ZjuVHBfbwGxLr1IIPZpfYsSnzw4owYQOnoubRkezeiDz1XN1kGhPl7lcKXA=="3}
NOTICE: The key rotates, kid is how you keep up
Each signing key has an id and we publish a new id when one is retired. Look the key up by kid, keep the old ones around until every token you could still receive has expired, and treat an unknown kid as a rejection rather than as a reason to ask the response where to find the key.

Java

A JWS ES256 signature is the raw 64 byte R||S form, while Signature.getInstance("SHA256withECDSA") expects a DER encoded SEQUENCE of two INTEGERs, so the example wraps the raw bytes in DER before handing them to the JDK.

LicenseTokenVerifier.java
Java
1import java.io.ByteArrayOutputStream;2import java.nio.charset.StandardCharsets;3import java.security.KeyFactory;4import java.security.PublicKey;5import java.security.Signature;6import java.security.spec.X509EncodedKeySpec;7import java.util.Base64;8import java.util.Map;9import java.util.regex.Matcher;10import java.util.regex.Pattern;11 12public final class LicenseTokenVerifier {13 14    private static final String JWS_TYP = "ULS-LIC";15    private static final long CLOCK_SKEW_SECONDS = 300L;16 17    private static final Map<String, String> KEYS = Map.of(18        "2026-10",19        "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE5v07+GZ7jOdgk0ImQ7ESxjClbo+ZjuVHBfbwGxLr1IIPZpfYsSnzw4owYQOnoubRkezeiDz1XN1kGhPl7lcKXA=="20    );21 22    private static final Pattern TEXT = Pattern.compile("\"([a-zA-Z]+)\"\\s*:\\s*(?:\"([^\"]*)\"|null)");23    private static final Pattern NUMBER = Pattern.compile("\"([a-zA-Z]+)\"\\s*:\\s*(-?\\d+)");24 25    public static boolean verify(String token, String productId, String hwid) {26        try {27            String[] parts = token.split("\\.", -1);28            if (parts.length != 3) {29                return reject("malformed compact JWS");30            }31 32            String header = decode(parts[0]);33            if (!"ES256".equals(text(header, "alg"))) {34                return reject("alg is not ES256");35            }36            if (!JWS_TYP.equals(text(header, "typ"))) {37                return reject("unexpected typ");38            }39 40            String spki = KEYS.get(text(header, "kid"));41            if (spki == null) {42                return reject("unknown kid");43            }44 45            byte[] raw = Base64.getUrlDecoder().decode(parts[2]);46            if (raw.length != 64) {47                return reject("signature is not 64 bytes");48            }49 50            Signature verifier = Signature.getInstance("SHA256withECDSA");51            verifier.initVerify(publicKey(spki));52            verifier.update((parts[0] + "." + parts[1]).getBytes(StandardCharsets.US_ASCII));53            if (!verifier.verify(toDer(raw))) {54                return reject("signature mismatch");55            }56 57            String payload = decode(parts[1]);58            long now = System.currentTimeMillis() / 1000L;59            if (now - CLOCK_SKEW_SECONDS > number(payload, "exp")) {60                return reject("token expired");61            }62            if (now + CLOCK_SKEW_SECONDS < number(payload, "nbf")) {63                return reject("token not active yet");64            }65            if (!productId.equals(text(payload, "aud"))) {66                return reject("aud mismatch");67            }68 69            String claimHwid = text(payload, "hwid");70            if (hwid != null && (claimHwid == null || !hwid.equalsIgnoreCase(claimHwid))) {71                return reject("hwid mismatch");72            }73            return true;74        } catch (Exception error) {75            return reject(String.valueOf(error.getMessage()));76        }77    }78 79    private static PublicKey publicKey(String spkiBase64) throws Exception {80        byte[] der = Base64.getDecoder().decode(spkiBase64);81        return KeyFactory.getInstance("EC").generatePublic(new X509EncodedKeySpec(der));82    }83 84    private static byte[] toDer(byte[] raw) throws Exception {85        byte[] r = derInteger(raw, 0);86        byte[] s = derInteger(raw, 32);87        int bodyLength = r.length + s.length;88        ByteArrayOutputStream out = new ByteArrayOutputStream();89        out.write(0x30);90        out.write(bodyLength);91        out.write(r);92        out.write(s);93        return out.toByteArray();94    }95 96    private static byte[] derInteger(byte[] raw, int offset) {97        int start = offset;98        while (start < offset + 31 && raw[start] == 0x00) {99            start++;100        }101        int length = offset + 32 - start;102        boolean pad = (raw[start] & 0x80) != 0;103        byte[] out = new byte[2 + length + (pad ? 1 : 0)];104        out[0] = 0x02;105        out[1] = (byte) (length + (pad ? 1 : 0));106        if (pad) {107            out[2] = 0x00;108        }109        System.arraycopy(raw, start, out, pad ? 3 : 2, length);110        return out;111    }112 113    private static String decode(String segment) {114        return new String(Base64.getUrlDecoder().decode(segment), StandardCharsets.UTF_8);115    }116 117    private static String text(String json, String name) {118        Matcher matcher = TEXT.matcher(json);119        while (matcher.find()) {120            if (name.equals(matcher.group(1))) {121                return matcher.group(2);122            }123        }124        return null;125    }126 127    private static long number(String json, String name) {128        Matcher matcher = NUMBER.matcher(json);129        while (matcher.find()) {130            if (name.equals(matcher.group(1))) {131                return Long.parseLong(matcher.group(2));132            }133        }134        throw new IllegalArgumentException("missing numeric claim " + name);135    }136 137    private static boolean reject(String reason) {138        System.out.println("[license] token rejected: " + reason);139        return false;140    }141}

Node.js

Node expects a DER signature by default too, so pass dsaEncoding: "ieee-p1363" to tell it the bytes are the raw R||S form instead of converting them yourself.

verify.mjs
JavaScript
1import { createPublicKey, verify } from "node:crypto"2 3const JWS_ALG = "ES256"4const JWS_TYP = "ULS-LIC"5const CLOCK_SKEW_SECONDS = 3006 7const KEYS = {8  "2026-10":9    "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE5v07+GZ7jOdgk0ImQ7ESxjClbo+ZjuVHBfbwGxLr1IIPZpfYsSnzw4owYQOnoubRkezeiDz1XN1kGhPl7lcKXA==",10}11 12const keyCache = new Map()13 14function publicKeyFor(kid) {15  if (!keyCache.has(kid)) {16    keyCache.set(17      kid,18      createPublicKey({ key: Buffer.from(KEYS[kid], "base64"), format: "der", type: "spki" }),19    )20  }21  return keyCache.get(kid)22}23 24export function verifyLicenseToken(token, productId, hwid = null) {25  const parts = token.split(".")26  if (parts.length !== 3) throw new Error("Malformed compact JWS")27 28  const header = JSON.parse(Buffer.from(parts[0], "base64url").toString("utf8"))29  if (header.alg !== JWS_ALG) throw new Error("Unsupported alg " + header.alg)30  if (header.typ !== JWS_TYP) throw new Error("Unexpected typ " + header.typ)31 32  const spki = KEYS[header.kid]33  if (!spki) throw new Error("Unknown kid " + header.kid)34 35  const ok = verify(36    "sha256",37    Buffer.from(parts[0] + "." + parts[1], "ascii"),38    { key: publicKeyFor(header.kid), dsaEncoding: "ieee-p1363" },39    Buffer.from(parts[2], "base64url"),40  )41  if (!ok) throw new Error("Signature mismatch")42 43  const payload = JSON.parse(Buffer.from(parts[1], "base64url").toString("utf8"))44  const now = Math.floor(Date.now() / 1000)45  if (now - CLOCK_SKEW_SECONDS > payload.exp) throw new Error("Token expired")46  if (now + CLOCK_SKEW_SECONDS < payload.nbf) throw new Error("Token not active yet")47  if (payload.aud !== productId) throw new Error("aud mismatch")48  if (hwid !== null && String(payload.hwid ?? "").toUpperCase() !== hwid.toUpperCase()) {49    throw new Error("hwid mismatch")50  }51  return payload52}

Python

The cryptography library takes DER as well, so the two 32 byte halves are packed back into an ASN.1 signature with encode_dss_signature before verifying.

verify.py
Python
1import base642import json3import time4 5from cryptography.exceptions import InvalidSignature6from cryptography.hazmat.primitives import hashes7from cryptography.hazmat.primitives.asymmetric import ec8from cryptography.hazmat.primitives.serialization import load_der_public_key9from cryptography.hazmat.primitives.asymmetric.utils import encode_dss_signature10 11JWS_ALG = "ES256"12JWS_TYP = "ULS-LIC"13CLOCK_SKEW_SECONDS = 30014 15KEYS = {16    "2026-10": "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE5v07+GZ7jOdgk0ImQ7ESxjClbo+ZjuVHBfbwGxLr1IIPZpfYsSnzw4owYQOnoubRkezeiDz1XN1kGhPl7lcKXA==",17}18 19 20def b64url(value):21    return base64.urlsafe_b64decode(value + "=" * (-len(value) % 4))22 23 24def verify_license_token(token, product_id, hwid=None):25    header_b64, payload_b64, signature_b64 = token.split(".")26 27    header = json.loads(b64url(header_b64))28    if header.get("alg") != JWS_ALG:29        raise ValueError("unsupported alg")30    if header.get("typ") != JWS_TYP:31        raise ValueError("unexpected typ")32 33    spki = KEYS.get(header.get("kid"))34    if spki is None:35        raise ValueError("unknown kid")36 37    public_key = load_der_public_key(base64.b64decode(spki))38    signing_input = (header_b64 + "." + payload_b64).encode("ascii")39 40    raw = b64url(signature_b64)41    der = encode_dss_signature(42        int.from_bytes(raw[:32], "big"),43        int.from_bytes(raw[32:], "big"),44    )45    try:46        public_key.verify(der, signing_input, ec.ECDSA(hashes.SHA256()))47    except InvalidSignature as error:48        raise ValueError("signature mismatch") from error49 50    payload = json.loads(b64url(payload_b64))51    now = int(time.time())52    if now - CLOCK_SKEW_SECONDS > payload["exp"]:53        raise ValueError("token expired")54    if now + CLOCK_SKEW_SECONDS < payload["nbf"]:55        raise ValueError("token not active yet")56    if payload["aud"] != product_id:57        raise ValueError("aud mismatch")58    if hwid is not None and str(payload.get("hwid") or "").upper() != hwid.upper():59        raise ValueError("hwid mismatch")60    return payload

Before you ship

  • The token is a compact JWS with exactly three dot separated segments.
  • The protected header alg is exactly ES256. Never accept alg none and never follow the header to pick an algorithm.
  • The protected header typ is ULS-LIC.
  • The protected header kid selects a key from the keys you ship with, not from anything the response told you to fetch.
  • The signature verifies against that key over the ASCII bytes of header.payload.
  • exp is in the future, allowing at most a small clock skew.
  • nbf is in the past, allowing at most a small clock skew.
  • aud equals your product id.
  • hwid equals the hardware id you sent, compared case insensitively.
  • The data tree you act on comes from the verified payload, not from the parsed response body.
Prefer a bundled library?

UlisesLib.jar does the same validation for you, with retries, HWID collection and the auto-updater built in. It is a dependency you shade into your jar rather than a contract you implement.

Using the SDK
PreviousValidate a LicenseNextEndpoint Best Practices

Built by Ulises - Citymoon Dynamics

Ulises LicensesUlises Licenses

License management for Minecraft plugins and Java applications.

Discord

Legal

  • Terms of Service
  • Privacy Policy
  • Refund Policy
  • DPA

Docs

  • Overview
  • Installation
  • Plugin Integration
  • Complete Example
  • API Methods
  • Connection Management
  • Auto-Update
  • Best Practices
  • Endpoint Overview
  • Validate a License
  • Verify the Response
  • Endpoint Best Practices

History

  • Announcements
  • Citymoon Dynamics

Company

  • About
  • Security
  • Status
  • Contact

Solutions

  • Java & Kotlin
  • Minecraft Plugins
  • Choosing Licensing
  • BuiltByBit
  • Polymart

© 2025-2030 Citymoon Dynamics (citymoon.org). All rights reserved.