A Kotlin/JVM Implementation of Platform-Agnostic Security Tokens - https://paseto.io
Paseto is everything you love about JOSE (JWT, JWE, JWS) without any of the many design deficits that plague the JOSE standards.
Paseto (Platform-Agnostic SEcurity TOkens) is a specification and reference implementation for secure stateless tokens.
Unlike JSON Web Tokens (JWT), which gives developers more than enough rope with which to hang themselves, Paseto only allows secure operations. JWT gives you "algorithm agility", Paseto gives you "versioned protocols". It's incredibly unlikely that you'll be able to use Paseto in an insecure way.
Caution: Neither JWT nor Paseto were designed for stateless session management. Paseto is suitable for tamper-proof cookies, but cannot prevent replay attacks by itself.
- JDK 17+
| Type | V1 | V2 | V3 | V4 |
|---|---|---|---|---|
| local | ✓ | ✓ | ✓ | ✓ |
| public | ✓ | ✓ | ✓ | ✓ |
| Feature | Status |
|---|---|
| JsonToken | ✓ |
| PASERK | planned |
Add net.aholbrook.paseto:paseto:0.9.0 your dependencies.
dependencies {
implementation('net.aholbrook.paseto:paseto:0.9.0')
}// First create a key to encrypt/decrypt the token.
// Please remember to save the key!
val key: SymmetricKey = SymmetricKey.generate(Version.V4)
// Next we create the token service which is used to encode and decode tokens
val service = tokenService(Version.V4, Purpose.Local { _ -> key })
// Create a token
val token: Token = token {
tokenId = "session-123"
audience = "mobile-app"
}
// Encode the token
val encoded: String = service.encode(token)
// And finally decode the previously encoded token
val decoded: Token = service.decode(encoded)TL;DR use V4
When setting up the Token Service we selected Version.V4. There are 4 versions available, here's a quick table to outline the differences:
| Purpose | NIST | Notes |
|---|---|---|
| V1 | Yes | Uses NIST-approved primitives: RSA-PSS for public tokens and AES-CTR + HMAC-SHA384 for local tokens. Considered legacy and mainly kept for compatibility. |
| V2 | No | Uses modern non-NIST primitives: Ed25519 and XChaCha20-Poly1305. Designed for simplicity and strong security but not NIST compliant. Considered legacy and mainly kept for compatibility. |
| V3 | Yes | Uses NIST primitives: ECDSA P-384 for public tokens and AES-CTR + HMAC-SHA384 for local tokens. Created specifically for NIST/FIPS environments. |
| V4 | No | Uses modern non-NIST primitives: Ed25519 and XChaCha20-Poly1305 (same crypto family as V2 but with protocol improvements). |
Generally you should select V4 unless you require NIST primitives in which case you should use V3. If you decide to use V3, please review the official PASETO Questions For Security Auditors. This library should meet these requirements however it has not been independently verified.
When creating a token service with PASETO, you must choose between two token types: local and public.
| Purpose | Encrypted | Authentication | Asymmetric |
|---|---|---|---|
| local | ✓ | ✓ | |
| public | ✓ | ✓ |
Both token types include authentication, meaning any modification to the token will be detected and rejected.
Local tokens are encrypted & authenticated using a symmetric key. The same secret key is required to both create and read the token. This makes local tokens appropriate when the same service both issues and consumes the tokens.
Public tokens are signed but not encrypted. Their contents are visible to anyone, but the signature can only be produced using the issuer’s private key. Other parties can verify the token using the corresponding public key, allowing them to confirm authenticity without being able to create valid tokens themselves.
In short:
- Local → private, encrypted tokens shared between trusted parties.
- Public → readable tokens that can be verified by anyone with the public key but only issued by the holder of the private key.
val keyPair = KeyPair.generate(Version.V4) // remember to save the keys!
val service = tokenService(Version.V4, Purpose.Public { keyPair.copy() })
val token = token { tokenId = "abc" }
val encoded = service.encode(token)
val decoded = service.decode(encoded)Keys can be generated directly by the library. Each PASETO version defines the
required key size and algorithm, so the generation functions take a Version
parameter.
For local tokens, a single SymmetricKey can be generated:
val key = SymmetricKey.generate(Version.V4)For public tokens, a key pair can be generated:
val keyPair = KeyPair.generate(Version.V4)This produces:
- an
AsymmetricSecretKeyused for signing - an
AsymmetricPublicKeyused for verification
Key generation is typically performed once during system setup and the resulting keys are stored in a secure location. The same generation functions are used for the other PASETO versions.
The two purposes use different key types. Local tokens use a SymmetricKey,
meaning the same key is used for both encryption and decryption. Public tokens
use an asymmetric key pair consisting of an AsymmetricSecretKey for signing
and an AsymmetricPublicKey for verifying the signature.
For this reason, the selected Purpose provides a lambda responsible for loading the appropriate key material. When constructing the Token Service and selecting the purpose, you also supply a function that loads the required key(s) for each token operation.
val service = tokenService(
version = Version.V4,
purpose = Purpose.Local { _ ->
SymmetricKey.ofBase64Url(loadKeyFromEnv(), Version.V4)
}
)Symmetric Keys:
| Method | Description |
|---|---|
SymmetricKey.ofRawBytes() |
Load key material directly from raw bytes. |
SymmetricKey.ofHex() |
Load a key encoded as hexadecimal. |
SymmetricKey.ofBase64Url() |
Load a key encoded using Base64URL. |
Asymmetric Secret Keys:
| Method | Description |
|---|---|
AsymmetricSecretKey.ofRawBytes() |
Load from raw private key bytes. |
AsymmetricSecretKey.ofHex() |
Load a hexadecimal encoded private key. |
AsymmetricSecretKey.ofBase64Url() |
Load a Base64URL encoded private key. |
AsymmetricSecretKey.ofPem() |
Load a private key encoded as PEM. |
Asymmetric Public Keys:
| Method | Description |
|---|---|
AsymmetricPublicKey.ofRawBytes() |
Load from raw public key bytes. |
AsymmetricPublicKey.ofHex() |
Load a hexadecimal encoded public key. |
AsymmetricPublicKey.ofBase64Url() |
Load a Base64URL encoded public key. |
AsymmetricPublicKey.ofPem() |
Load a PEM encoded public key. |
Key Pairs:
| Method | Description |
|---|---|
KeyPair.ofPkcs12() |
Load private/public key from a .p12 stream |
The key loading lambda (keyProvider() function) receives the token's tainted footer, allowing applications to select keys dynamically. This is useful for implementing key rotation or key rings, where the footer contains metadata such as a key identifier (kid).
Simple KeyRing Example:
val keyRing = mapOf<String, SymmetricKey>()
val service = tokenService(
version = Version.V4,
purpose = Purpose.Local { footer ->
val kid = (footer as? TaintedClaimFooter)?.keyId
?: error("Missing key id")
keyRing[kid] ?: error("Unknown key id")
}
)Because the footer is extracted before cryptographic verification, it is represented as a tainted footer. Applications should treat these values as untrusted input until the token has been successfully verified.
Key material is intended to be reused, but individual key instances can optionally be configured to automatically clear themselves from memory after use.
Keys support two lifecycle modes:
| Lifecycle | Behavior |
|---|---|
PERSISTENT |
The key instance remains usable after operations. This is the default behavior. |
EPHEMERAL |
The key instance is cleared from memory after use. |
When an ephemeral key is used in a PASETO operation, its internal key
material is securely zeroed once the operation completes. Any subsequent
attempt to use the same instance will throw a KeyClearedException.
This helps reduce the chance that sensitive key material could appear in memory dumps or long-lived heap objects.
Example:
val key = SymmetricKey.generate(Version.V4, lifecycle = KeyLifecycle.EPHEMERAL)
val service = tokenService(Version.V4, Purpose.Local { _ -> key })
service.encode(token)
service.encode(anotherToken) // `KeyClearedException` will be thrownWhen using ephemeral keys, the keyProvider function should return a fresh key instance for each operation.
Keys that are loaded or generated are PERSISTENT by default. Copies created
using the copy() function are EPHEMERAL by default. Calling clear() will
zero the key material regardless of the lifecycle. The lifecycle only affects
the automatic clearing behavior performed internally after a cryptographic
operation. Attempting to use a cleared key, regardless of it's lifecycle will
raise a KeyClearedException.
Lifecycle rules do not apply to AsymmetricPublicKey.
Keys can be exported to several formats for storage.
Symmetric Keys:
| Method | Description |
|---|---|
SymmetricKey.toHex() |
Export the key as hexadecimal. |
SymmetricKey.toBase64Url() |
Export the key as Base64URL. |
Asymmetric Secret Keys:
| Method | Description |
|---|---|
toHex() |
Export the private key as hexadecimal. |
toBase64Url() |
Export the private key as Base64URL. |
toPem() |
Export the private key in PEM format. |
Asymmetric Public Keys:
| Method | Description |
|---|---|
toHex() |
Export the public key as hexadecimal. |
toBase64Url() |
Export the public key as Base64URL. |
toPem() |
Export the public key in PEM format. |
A PASETO token contains a collections of claims that describe the identity, context, and validity of the token. These claims are stored in the token body and are protected by the PASETO protocol.
The Token class represents the structured claims contained in a token.
The standard PASETO claims are all supported directly on the Token class:
| Field | Claim | Description |
|---|---|---|
issuer |
iss |
Identifies the principal that issued the token (typically your authentication service). |
subject |
sub |
Identifies the subject of the token, usually the user or entity the token represents. |
audience |
aud |
Identifies the intended recipient of the token (e.g., a specific service or API). |
expiresAt |
exp |
The time after which the token must not be accepted. |
notBefore |
nbf |
The time before which the token must not be accepted. |
issuedAt |
iat |
The time the token was issued. |
tokenId |
jti |
A unique identifier for the token. |
All time-based claims are stored as Instant values and truncated to second
precision when the token is built.
In addition to the standard claims, tokens may include arbitrary custom claims.
Custom claims allow applications to include additional context such as:
- roles or permissions
- organization identifiers
- session metadata
- feature flags
The claims API closely mirrors the buildJsonObject API from kotlinx.serialization.
Example:
val token = token {
issuer = "auth-service"
subject = "user:123"
claims {
put("role", "admin")
put("login_count", 42)
put("active", true)
}
}Nested objects can be created using claimObject:
claims {
put("user", claimObject {
put("id", "123")
put("role", "admin")
})
}Arrays can be created using claimArray:
claims {
put("permissions", claimArray {
add("read")
add("write")
add("delete")
})
}Claims can be accessed through the ClaimElement API. Each element may be an object, array, or primitive value.
val role = token.claims["role"]?.asType<String>()Primitive values can also be accessed through the convenience properties:
| Property | Type |
|---|---|
stringOrNull |
String? |
booleanOrNull |
Boolean? |
intOrNull |
Int? |
longOrNull |
Long? |
doubleOrNull |
Double? |
For example:
val loginCount = token.claims["login_count"]?.primitiveOrNull?.intOrNullOr for structured values:
val user = token.claims["user"]?.objectOrNull
val permissions = token.claims["permissions"]?.arrayOrNullAn internal escape hatch is also provided for accessing the raw JSON
representation of claims provided you have kotlinx.serialization available:
@OptIn(InternalApi::class)
val json = token.claimsJson()The escape hatch requires Opt-in as the API is subject to change, use only if required and at your own risk.
PASETO supports an optional footer attached to the token. The footer is authenticated but not encrypted, regardless of the token purpose. This means its contents are visible but protected against modification once the token has been verified.
Footers are commonly used to carry metadata required to process the token, such as identifying which key should be used to verify it.
Two footer formats are supported.
| Type | Description |
|---|---|
StringFooter |
A simple string value attached to the token. |
ClaimFooter |
A structured footer containing standard metadata fields and arbitrary claims. |
A string footer is the simplest form and is useful when only a small piece of metadata needs to be attached.
val token = token {
issuer = "auth-service"
footer("key-2026-01")
}A string footer is stored in a StringFooter. You can create a footer
outside the token builder via the same footer(string) function:
val basicStringFooter = footer("key-2026-02")Structured footers allow metadata and additional claims to be attached using
the footer {} DSL. These are stored in a ClaimFooter.
The PASETO standard reserves two footer claims:
| Field | Claim | Description |
|---|---|---|
keyId |
kid |
Identifies the key used to sign or encrypt the token. |
wrappedKey |
wpk |
An optional wrapped key encoded as a PASERK. |
These standard claims are supported directly on the footer builder DSL:
val token = token {
issuer = "auth-service"
footer {
keyId = "key-2026-01"
wrappedKey = "encrypted-key"
}
}A footer with structured claims is stored in a ClaimFooter. You can create a footer
outside the token builder via the same footer { ... } builder function.
If you're using footer claims you should switch the parsing mode to FooterParseMode.CLAIMS for strict JSON decoding. More on this below.
Structured Footers support arbitrary claims using the same API as the Token.
You can set any claim except the two reserved keywords kid and wpk.
Example:
val footer = footer {
keyId = "key-2026-01"
put("routing", claimObject {
put("cluster", "auth-cluster-a")
put("environment", "production")
})
put("processing_hints", claimArray {
add("cacheable")
add("edge-verify")
})
}When decoding a token, an expected footer may optionally be provided. If a footer is supplied, the decoded token footer must match exactly. If the footers do not match, decoding will fail.
val expectedFooter = footer {
keyId = "key-2026-01"
}
val token = service.decode(encodedToken, footer = expectedFooter)
// throws IncorrectFooterException if not an exact matchThis ensures that the token was issued with the expected footer metadata.
Footers are subject to several limits to prevent excessive memory usage and protect against malicious inputs when parsing tokens.
These limits apply when encoding and decoding footers.
| Option | Default | Description |
|---|---|---|
parseMode |
AUTO |
The footer parse mode (AUTO, CLAIMS, STRING), more info below. |
maxLength |
8192 |
Maximum allowed length of the footer string. |
maxDepth |
2 |
Maximum JSON object nesting depth for claim footers. |
maxKeys |
512 |
Maximum number of keys allowed in a JSON footer. |
If these limits are exceeded, decoding will fail and an exception will be thrown.
maxDepth and maxKey limits only apply when the footer is processed as JSON,
either via AUTO or CLAIMS parsing mode. All footers have the maxLength
limitation enforced.
Multiple strategies are supported for interpreting footer data when a token is decoded.
| Mode | Behavior |
|---|---|
AUTO |
Attempts to parse object-like footer text ({...}) as JSON claims. If parsing fails, the footer is treated as a plain string. |
CLAIMS |
Requires the footer to be valid JSON. Parsing or validation failures will throw an exception. |
STRING |
Always treats the footer as a plain string without attempting JSON parsing. Max length limit still applies |
Example configuration:
val service = tokenService(Version.V4, Purpose.Public { key.copy() }) {
footerOptions {
parseMode = FooterParseMode.CLAIMS
maxLength = 4096
maxDepth = 2
maxKeys = 256
}
}When parsing a token, the footer may be extracted before the token has been cryptographically verified. In this case the footer must be treated as untrusted input.
For this reason the library exposes a separate type:
| Type | Description |
|---|---|
TaintedStringFooter |
Unverified variant of StringFooter. |
TaintedClaimFooter |
Unverified variant of ClaimFooter. |
These types indicate that the footer may have been tampered with and should not be trusted until the token has been successfully verified.
A verified footer can be converted to its tainted representation using:
val footer = footer("abcd")
val tainted = footer.taint()This allows comparisons between expected footer values and those extracted from an incoming token.
There is no inverse operation by design.
Footer claims are store in the same ClaimObject as used for Token, so the
access pattern is identical.
Example:
val region = footer.claims["region"]?.asType<String>()An internal escape hatch is also provided for accessing the raw JSON
representation of footer claims provided you have kotlinx.serialization
available:
@OptIn(InternalApi::class)
val json = footer.claimsJson()The escape hatch requires Opt-in as the API is subject to change, use only if required and at your own risk.
When a token is encoded or decoded, the library can apply a set of validation rules to verify that the token's claims satisfy application requirements.
Rules are configured on the TokenService and are executed automatically during
token encoding and decoding.
Example configuration:
val service = tokenService(Version.V4, Purpose.Public { keyPair }) {
rules {
issuedBy = IssuedBy("auth-service")
forAudience = ForAudience("payments-api")
subject = Subject("user:42")
}
}If any rule fails during validation, decoding will throw a
MultipleValidationErrorsException containing all rule failures.
Rules are executed sequentially in the following order:
- Standard claim rules
- Custom rules, in the order they were added
Each rule receives:
- the decoded Token
- the current Mode (ENCODE or DECODE)
- the results of previously executed rules
Rules may either:
- complete successfully (RuleVerified)
- throw a RuleValidationException (RuleFailed)
All rule failures are collected and reported together as a
MultipleValidationErrorsException.
Several rules that validate common PASETO claims are included with the library.
| Rule | Claim | Description |
|---|---|---|
IssuedBy |
iss |
Ensures the token was issued by the expected issuer. |
ForAudience |
aud |
Ensures the token is intended for the expected audience. |
Subject |
sub |
Ensures the token subject matches the expected value. |
IdentifiedBy |
jti |
Ensures the token identifier matches the expected value. |
IssuedInPast |
iat |
Ensures the token was not issued in the future. |
NotBefore |
nbf |
Ensures the token is not used before its valid time. |
NotExpired |
exp |
Ensures the token has not expired. |
ValidAt |
iat, nbf, exp |
Ensures the token is valid at the current time. |
Two rules are enabled by default.
| Rule | Purpose |
|---|---|
IssuedInPast |
Ensures tokens were not issued in the future. |
NotExpired |
Ensures tokens are not expired. |
These can be overridden or removed when building the ruleset:
tokenService(Version.V4, Purpose.Local { _ -> key }) {
rules {
issuedInPast = null
notExpired = null
}
}If you wish to create tokens within an expiry time you must disable the
notExpired rule by setting it to null using the builder.
Applications may define additional validation logic using CustomRule.
val customRule = CustomRule { token, mode, results ->
val tier = token.claims["tier"]?.asType<String>() ?: return@CustomRule
if (!tier.constantTimeEquals("premium")) {
throw RuleValidationException("premium tier required", "tier", token)
}
}
rules {
forAudience = ForAudience("abc")
customRules.add(customRule)
}Custom rules run alongside built-in rules and participate in the same validation pipeline.
Rules run in two contexts:
| Mode | Description |
|---|---|
ENCODE |
Validates the token before it is encoded. |
DECODE |
Validates the token after it has been verified and decoded. |
Rules can perform different checks depending on the mode.
For example:
- During encoding, rules validate claim relationships.
- During decoding, rules validate the token against the current time.
When writing custom rules that compare sensitive token values, comparisons should be performed using constant-time equality checks.
Naïve comparisons such as:
token.subject == "user:42"may leak information through timing side channels. Attackers can exploit small timing differences to gradually guess secret values.
To prevent this, provides constant-time comparison helpers are provided:
| Function | Description |
|---|---|
ByteArray.constantTimeEquals(expected) |
Compares two byte arrays using constant-time semantics. |
String.constantTimeEquals(expected) |
Compares two strings by converting them to byte arrays and using constant-time comparison. |
Example usage inside a custom rule:
rules {
customRules += CustomRule { token, mode, _ ->
val tier = token.claims["tier"]?.asType<String>() ?: return@CustomRule
if (!tier.constantTimeEquals("premium")) {
throw RuleValidationException("premium tier required", "tier", token)
}
}
}To preserve constant-time behavior, the user-supplied value must be the receiver of the comparison:
// Correct
userInput.constantTimeEquals(expectedValue)
// Incorrect
expectedValue.constantTimeEquals(userInput)This ensures the function performs the same amount of work regardless of whether the values match.
Rules can be defined independently of a TokenService and reused across
multiple services.
The top-level rules {} function creates a reusable Rules instance:
val authRules = rules {
issuedBy = IssuedBy("auth-service")
forAudience = ForAudience("payments-api")
issuedInPast = IssuedInPast()
notExpired = NotExpired()
}This ruleset can then be shared by multiple token services:
val serviceA = tokenService(Version.V4, Purpose.Public { _ -> keyPair }) {
rules(authRules)
}
val serviceB = tokenService(Version.V4, Purpose.Local { _ -> key }) {
rules(authRules)
}This is useful when the same validation policy is applied across multiple services or environments.
An existing ruleset can also be extended using the builder DSL:
val adminRules = rules(authRules) {
subject = Subject("admin")
}This creates a new Rules instance that includes all rules from authRules plus
any additional rules defined in the new block.