License as JWT: Architecture and Implementation for Spring Boot

published: ⏰ 14

The task of adding licensing to products is not new and is often encountered. In this article, I wanted to present an example implementation for Java applications – from choosing a license format to declarative protection of business methods. The approach is relatively simple, without using secure storage, chains of license servers, or anything else.

At each stage, I will show alternatives and explain why I chose one option over another. As references and practical examples, the tekBlueprint.authz project (currently an internal closed project with shared components and SPI) and tekBlueprint.infra will be used.

Problem and Goals

A commercial application needs to be protected from unauthorized use. The following is required:

  • Access control for premium features by feature flags (feature flag).
  • Entity quantity limits – projects, users, services (limit).
  • License expiration verification.
  • Ability to operate without a constant connection to a license server.
  • Protection of the license against forgery and unauthorized reading.

Therefore, we need to design a system that:

  1. Generates cryptographically protected licenses containing plan, feature, and limit data.
  2. Allows the application to verify the license at startup (offline or online).
  3. Gives developers a simple mechanism for protecting methods – without embedding checks into business logic.
  4. Ensures graceful degradation – the application starts even without a license, but with limitations.

Choosing a License Format

Off the top of my head, there are five obvious approaches to storing and transmitting license data:

  1. Proprietary binary format – a custom data structure serialized into bytes. Requires writing a parser on both sides, no standard debugging tools, high TCO for maintenance.
  2. XML + XML-DSig / XML-Encryption – W3C standard, cryptographically correct. I used this approach mostly in the past; it is quite reliable and functional. But XML is bulky, parsing is slow, and there is no built-in support in the Spring Boot ecosystem – you would have to write an integration from scratch.
  3. JSON + HMAC/SHA256 – a simple approach, signing JSON with a hash. The problem – data is not encrypted, and HMAC requires exchanging a secret key, which is less convenient for public verification. Moreover, HMAC does not provide confidentiality – anyone with the token can see all the data.
  4. X.509 certificates with extensions – a common and reliable standard, but too rigid: it is difficult to add arbitrary limits and features without modifying the certificate structure. Inconvenient for dynamic generated data.
  5. JOSE (JWT / JWS / JWE) – IETF standard, native Java support via the nimbus-jose-jwt library, compact format, built-in signing and encryption algorithms.

Key Formation Approaches

Jumping ahead, I will say that two license verification modes are needed: offline and online. That means using both JWS and JWE.

With JWE, data can be signed and encrypted in two ways: Sign-then-Encrypt and Encrypt-then-Sign. RFC 8725 §3 recommends Encrypt-then-Sign as the preferred option because the signature covers the encrypted data and guarantees integrity before decryption. However, for licenses, Sign-then-Encrypt is often chosen because:

CriterionSign-then-EncryptEncrypt-then-Sign
Signature confidentialityThe signature is hidden inside the encrypted token – an attacker cannot analyze itThe signature is visible in plain text
Operation orderFirst sign the original data, then encrypt the entire packageFirst encrypt, then sign the ciphertext – the recipient verifies the signature before decryption
Practice for licensesMore convenient: the signature guarantees integrity of the original data, while encryption adds confidentialityA stricter cryptographic approach, but more complex to implement and debug

Both options are cryptographically correct. The choice depends on requirements for signature confidentiality and verification convenience on the client side.

The full key formation cycle looks like this:

Text
Claims (JSON) → JWS signature → JWE encryption

It is important to remember that in a JWE scheme, for the client to verify and read such a license offline, you will have to embed keys directly into the application code, meaning:

  1. For signature verification. The server’s public key is embedded in the application. This is safe.
  2. For decryption. You will have to embed a private decryption key in the application. This is the main risk. If an attacker decompiles your application, they will extract this private key. If the private decryption key is stolen, others’ license data can be decrypted. But it is still not possible to create new licenses because the server’s signing private key is missing.

In my case, there is no confidential data in the license, and for offline mode only JWS without encryption can be used – the license is readable but protected from forgery by a signature. This is a compromise between security and convenience: license data (plan, features) are not secret, and the signature guarantees integrity.

License Data Structure

This is regular JSON, and you can structure it however you like. For example, the license contains two levels of data – standard JWT claims and your nested metadata object:

JSON
{
  "jti": "lic-8f3a9c1b-4d2e",
  "iss": "https://lic.tekblueprint.org",
  "sub": "acme-corp",
  "iat": 1750000000,
  "exp": 1781536000,
  "metadata": {
    "plan": "enterprise",
    "region": "eu-west-1",
    "features": [
      "ai.assistants",
      "automation.hw-plan"
    ],
    "limits": {
      "software.projects-max": 50,
      "software.systems-max": -1
    }
  }
}

Why a nested metadata structure, rather than flat JSON:

  • Standard claims (jti, iss, sub, iat, exp) have clear purposes per RFC 7519 – the parser expects them at the top level
  • Operational data (plan, features, limits) is isolated in a separate object – this simplifies extension without conflicts with future JWT standards
  • In code, this is reflected by two classes: LicenseClaims for standard fields and LicenseMetadata for business data

As an alternative, you could store features and limits as separate attributes at the top level:

JSON
{
  "jti": "...",
  "exp": ...,
  "plan": "enterprise",
  "features": [
    ...
  ],
  "limits": {
    ...
  }
}

The problem with this approach is that when adding new fields (e.g., quotas, addons), they would be “scattered” across the top level. Nested metadata solves this: any new fields are added inside a single object.

License Generation

If you have time and inclination, you can write a web service with a UI for license management. This is convenient and accessible for users.

But there is also a faster alternative in the form of a CLI tool for administrators. Even in this variant, there could be a separate license server that the CLI connects to via gRPC/REST.

An example of possible CLI usage below. Somewhat cumbersome, but all of this can be automated by creating additional scripts, using CSVs for batch license creation, and so on.

Shell
authz license create \
  --signing-key signing-private.pem \
  -t acme-corp \
  --plan enterprise \
  -F ai.assistants,automation.hw-plan \
  -L software.projects-max=50,software.systems-max=-1 \
  -d 365 \
  --no-encrypt

Bringing everything together:

  1. Built-in generation in the application – the private key is stored inside the application. This is a strange solution and should never be done this way. If an attacker gains access to the server, they will be able to generate arbitrary licenses.
  2. A separate licensing service (License Server) – as I mentioned above, license generation and storage on a dedicated server with a database. Suitable for online mode, where the application periodically contacts the server to check status. More complex to deploy, but provides control over license revocation.
  3. CLI generator – keys are held by the administrator, licenses are generated locally and transferred to the client. Minimal infrastructure, well-suited for offline licenses.

Signing Algorithms

Here you need to choose based on reasonableness and sufficiency. For example:

AlgorithmKeySpeed
ES256 (ECDSA P-256)256 bitsFaster, smaller license
RS256 (RSA)2048+ bitsSlower, larger license

ES256 is preferred: at the same cryptographic strength, the signature is 4 times smaller, which matters for licenses transmitted over the network or stored in environment variables.

Verification Modes

Depending on usage conditions and deployment, several license verification modes can be supported.

Offline Mode (JWS)

If the application is deployed in an isolated environment without access to external services, you need to verify the license at startup using only local resources. For this, the license is formed as a JWS and signed with a private key. The application embeds a public key for signature verification:

Advantages:

  • No network dependency at startup.
  • Verification takes milliseconds.
  • A public key is not secret and can be freely distributed with the application.

Disadvantages:

  • Impossible to revoke a license remotely – the license remains valid until exp.
  • License data is readable (base64-decode payload) – suitable if plan and features are not secret.

Online Mode (JWE + server-side verification)

If real-time control over licenses is required: revocation capability, usage audit, protection of license data from reading. Then the license is formed as a JWE. The application decrypts the license with its private key and sends a request to the license server to check status:

Advantages:

  • License data is encrypted – without the decryption key, the content is unreadable.
  • The server can revoke a license by changing its status in the database.
  • The license hash on the server guarantees that the license was not modified.

Disadvantages:

  • Network connection required at startup.
  • Additional infrastructure – a license server with a database.
  • More complex deployment: you need to manage encryption key pairs on both sides.

Hybrid Option: Server as CA

The main problem of classic online mode – the private decryption key is stored in the application. If the application contacts the license server anyway, local decryption is redundant. The key stays on the server:

The server acts as a CA: it decrypts the original token and issues a short-lived JWS ticket with license data, signed by the server’s public key. The application verifies the ticket signature offline, but the data is valid only for a limited time (e.g., 24 hours). The private decryption key never leaves the server, and the application operates autonomously between requests. If the license server is temporarily unavailable, the application continues to operate on an old ticket until TTL expires.

Advantages:

  • Private keys (signing and encryption) live only on the license server. Zero secrets in the application.
  • The server controls the full cycle: decryption → signature verification → revocation check in DB.
  • The token is transmitted over HTTPS – interception yields an encrypted package, useless without the key.

Disadvantages:

  • The application is fully dependent on the license server. If the server is unavailable at startup – the application will not start (retry and fallback strategy needed).
  • Each startup = a network request. For a cluster of hundreds of instances, this is load on the license server.
  • The server returns decrypted license data in the response – the endpoint needs protection (mTLS, rate limit).

Mode Comparison

CriterionOffline (JWS)Online (JWE)
Data confidentialityNo (payload is readable)Yes
License revocationOnly upon exp expiryInstantly via server
Network dependencyNoYes, at startup
Deployment complexityMinimalRequires a license server
ApplicabilityFor simple licensesFor extended enterprise licenses

All three approaches can be combined depending on customer requirements and infrastructure constraints. For example: offline mode for isolated environments, online or hybrid – when revocation control is needed.

License Verification at Application Startup

The problem: what to do if the license is missing? The application should not crash – it should progressively limit available functionality.

As an example solution, this is a multi-level license lookup chain:

Text
1. LICENSE_TOKEN (environment variable)
   ↓ no license
2. LicenseTokenProvider (custom Spring bean)
   ↓ no license
3. Bundled free license (e.g., built-in free license in classpath)
   ↓ no license
4. Grace period (synthetic 72-hour license, before full lockout)

Each level is a separate license source. If the current level does not return a license, the system moves to the next one. This ensures maximum deployment flexibility:

  • A CI/CD pipeline can pass the license through an environment variable.
  • A Docker container can mount a license file and read it via LicenseTokenProvider.
  • A distribution can include a built-in free license for a trial period.

The LicenseTokenProvider interface may be needed if the application requires some non-standard license storage source:

Java

@Component
@ConditionalOnProperty(name = "tb-authz.license.file")
public class LicenseTokenProviderImpl implements LicenseTokenProvider {

    @Value("${tb-authz.license.file}")
    private Path file;

    @Override
    public String provide() {
        if (file == null || !Files.exists(file)) {
            return null;  // Fall through to the next fallback level
        }
        try {
            log.info("Loading license token from {}", file);
            return Files.readString(file).trim();
        } catch (Exception e) {
            log.error("Cannot read license token from {}", file, e);
            return null;
        }
    }
}

Why @ConditionalOnProperty, rather than a regular @Component: the provider is activated only when the property tb-authz.license.file is present. If the property is not set – the bean is not created, and the system automatically falls through to the next level without configuration errors.

After successful license verification, it is better to cache its data in memory. This is necessary for adequate performance, since the license is verified once at startup, not on every call to a protected method.

Protecting Business Methods

The naive approach – embedding license checks directly into method code:

Java
// Bad: check mixed with business logic
public Project create(ProjectNewRecord record) {
    if (!license.hasFeature("projects")) {
        throw new LicenseException("Projects not licensed");
    }
    if (projectsRepository.count() >= license.getLimitValue("software.projects-max")) {
        throw new LicenseLimitExceededException(...);
    }
    // ... project creation business logic
}

Problems with this approach:

  • Business logic is cluttered with license checks.
  • Easy to forget to add a check in a new method.
  • Hard to maintain – licensing policy changes require modifying many files.
  • Impossible to centrally log checks.

Solution: AOP + Annotations

Using aspect-oriented programming (AOP) separates license verification from business logic. The developer simply places an annotation above a method, and the check is executed automatically via an interceptor.

This approach seemed most convenient using SpEL. Essentially it is analogous to @PreAuthorize from Spring Security:

Java
@PreAuthorize("hasPermission('infra-ai-assistant', 'use') and hasPermission('infra-software-system', 'create')")

A small comparison table of license verification approaches in business logic:

ApproachProsCons
Aspects (AOP)Declarative, centralized, does not clutter codeRequires understanding Spring AOP, harder to debug
Manual checkTransparent, easy to debugCode duplication, mixing of responsibilities
Filter / interceptor at HTTP levelWorks before entering the service layerDoes not protect internal calls between services; tied to web layer
Object factories with checksVerification during object constructionSimilar to AOP, but requires more manual work

@RequiresFeature

Checks for a feature in the license:

Java

@RequiresFeature("ai.assistants")
public SseEmitter generate(AssistantInfraChatQuery request) {
    // Code executes only if the license contains "ai.assistants"
}

The annotation supports SpEL for dynamic feature determination:

Java
// Feature determined from a method parameter
@RequiresFeature("#ctx.featureName")
public void execute(Context ctx) { ...}

// Feature taken from a Spring bean
@RequiresFeature("@configService.getRequiredFeature()")
public void process() { ...}

Internal operation of the aspect:

  1. The interceptor intercepts the method call via @Around advice.
  2. Retrieves data from the license.
  3. Checks that the license has not expired.
  4. Resolves the SpEL expression (if needed) to get the feature name.
  5. Verifies permission to use the feature.
  6. If the check passes – calls the original method.

@RequiresLimit

Checks a numeric limit with dynamic computation of the current value:

Java

@RequiresLimit(
        key = "software.projects-max",
        value = "@projectsRepository.count() + 1"
)
public Project create(ProjectNewRecord record) { ...}

Why + 1 – the expression computes the value after object creation. If there are already 49 projects in the database and the limit is 50 – the current value will be 49 + 1 = 50, which does not exceed the limit. On the next attempt: 50 + 1 = 51 > 50 – exception.

Complex expressions:

Java

@RequiresLimit(
        key = "software.stream-redirects-max",
        value = "#record.redirects != null ? @redirectRepo.countByProjectId(#record.projectId) + #record.redirects.size() : 0"
)
public StreamGenerator create(StreamNewRecord record) { ...}

Here, the possibility that redirects could be null is accounted for, and the current count in the database is summed with the number of new records from the request.

Multiple limits on one method:

Java

@RequiresLimit(key = "software.streams-max", value = "@streamRepo.countByProjectId(#record.projectId) + 1")
@RequiresLimit(key = "software.stream-redirects-max", value = "...")
public StreamGenerator create(StreamNewRecord record) { ...}

The annotations are @Repeatable – you can place multiple annotations of the same type. Check order: first all @RequiresFeature (@Order(1)), then all @RequiresLimit (@Order(2)). This is logical: if a feature is not licensed, there is no point in checking limits.

SpEL in Annotations

To avoid reinventing the wheel, already-working SpEL expressions are used, which have access to:

TokenDescriptionExample
#paramNameMethod parameters#record.projectId
@beanNameSpring beans by name@projectsRepository.count()
LiteralsNumbers, strings, boolean values"50", true

It would be possible to use simple literals and pass the computed value through a separate parameter. But SpEL provides flexibility: the expression describes how to compute current usage, rather than requiring the developer to do it manually in method code.

Error Handling

Why using unchecked (runtime) exceptions is better than checked ones:

  • License verification is an infrastructure task that business logic developers should not handle explicitly via try-catch.
  • The exception should “bubble up” to a global handler, which converts it into an HTTP response.
  • A checked exception would force every method to add throws LicenseException – noise in signatures.

If you are using Spring MVC, a good practice is to use @RestControllerAdvice + @ExceptionHandler for automatic conversion of errors into client-friendly formats.

Exporting License Information to the Frontend

The frontend needs to know which features are available to the user, so that unavailable UI elements can be hidden before making API requests. Without this information, the user will see a “Create Project” button, click it, and receive a 402/503 error.

One option is to pass license data through dynamic runtime.js

As an alternative, you can create a separate unprotected API endpoint and serve data through it.

An example of displaying license information, without additional localization for features and limits.

Conclusion

Licensing is an infrastructure task from the application’s perspective. It should not penetrate into the application’s business logic. A declarative approach through annotations and AOP ensures clean separation of concerns: the developer writes business code, while the licensing system automatically controls access.

Upon license expiration, a good approach is not complete lockout but gradual degradation of available capabilities. But in any case, existing user data must never be blocked; new data – can be.

A small note on choosing encryption and organizing restrictions in the application. The complexity of protection implementation should be proportional to the product’s value. The goal of licensing is not to create an insurmountable barrier (which is impossible), but to raise the entry threshold for unauthorized use. Additional methods that can be applied:

  • Encrypting licenses in memory complicates data reading via a debugger, cutting off superficial analysis attempts.
  • Embedding checks at the bean level requires knowledge of the application’s internal architecture to bypass.
  • Calls to license servers can be protected through mTLS and key rotation, making traffic interception less effective.
  • Obfuscation is not a protection method in itself, but combined with other measures it complicates reverse engineering.
  • Building a native application significantly complicates code analysis and debugging – sufficient for protection against most attacks.

No single method provides an absolute guarantee, but a comprehensive approach makes bypassing the license economically unfeasible for the vast majority of users.

License Java Spring-Boot Jwt Jose