License as JWT: Architecture and Implementation for Spring Boot
published: ⏰ 14The 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:
- Generates cryptographically protected licenses containing plan, feature, and limit data.
- Allows the application to verify the license at startup (offline or online).
- Gives developers a simple mechanism for protecting methods – without embedding checks into business logic.
- 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:
- 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.
- 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.
- 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.
- 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.
- 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:
| Criterion | Sign-then-Encrypt | Encrypt-then-Sign |
|---|---|---|
| Signature confidentiality | The signature is hidden inside the encrypted token – an attacker cannot analyze it | The signature is visible in plain text |
| Operation order | First sign the original data, then encrypt the entire package | First encrypt, then sign the ciphertext – the recipient verifies the signature before decryption |
| Practice for licenses | More convenient: the signature guarantees integrity of the original data, while encryption adds confidentiality | A 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:
Claims (JSON) → JWS signature → JWE encryptionIt 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:
- For signature verification. The server’s public key is embedded in the application. This is safe.
- 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:
{
"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:
LicenseClaimsfor standard fields andLicenseMetadatafor business data
As an alternative, you could store features and limits as separate attributes at the top level:
{
"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.
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-encryptBringing everything together:
- 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.
- 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.
- 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:
| Algorithm | Key | Speed |
|---|---|---|
| ES256 (ECDSA P-256) | 256 bits | Faster, smaller license |
| RS256 (RSA) | 2048+ bits | Slower, 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
| Criterion | Offline (JWS) | Online (JWE) |
|---|---|---|
| Data confidentiality | No (payload is readable) | Yes |
| License revocation | Only upon exp expiry | Instantly via server |
| Network dependency | No | Yes, at startup |
| Deployment complexity | Minimal | Requires a license server |
| Applicability | For simple licenses | For 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:
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:
@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:
// 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:
@PreAuthorize("hasPermission('infra-ai-assistant', 'use') and hasPermission('infra-software-system', 'create')")A small comparison table of license verification approaches in business logic:
| Approach | Pros | Cons |
|---|---|---|
| Aspects (AOP) | Declarative, centralized, does not clutter code | Requires understanding Spring AOP, harder to debug |
| Manual check | Transparent, easy to debug | Code duplication, mixing of responsibilities |
| Filter / interceptor at HTTP level | Works before entering the service layer | Does not protect internal calls between services; tied to web layer |
| Object factories with checks | Verification during object construction | Similar to AOP, but requires more manual work |
@RequiresFeature
Checks for a feature in the license:
@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:
// 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:
- The interceptor intercepts the method call via
@Aroundadvice. - Retrieves data from the license.
- Checks that the license has not expired.
- Resolves the SpEL expression (if needed) to get the feature name.
- Verifies permission to use the feature.
- If the check passes – calls the original method.
@RequiresLimit
Checks a numeric limit with dynamic computation of the current value:
@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:
@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:
@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:
| Token | Description | Example |
|---|---|---|
#paramName | Method parameters | #record.projectId |
@beanName | Spring beans by name | @projectsRepository.count() |
| Literals | Numbers, 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.