Modern Token Security: Implementing PASETO Tokens in Java

Introduction

In the world of stateless authentication, JSON Web Tokens (JWT) have long been the standard. However, JWT comes with significant security complexities and implementation pitfalls. PASETO (Platform-Agnostic Security Tokens) emerges as a secure alternative designed to eliminate common vulnerabilities found in JWT implementations.

For Java developers building secure applications, PASETO offers a simpler, cryptographically sound approach to token-based authentication. This article explores PASETO concepts and provides practical implementation guidance for Java applications.


What is PASETO?

PASETO is a specification for secure stateless tokens that provides:

  • Simplified cryptographic choices - No "algorithm" header to manipulate
  • Built-in security best practices - Eliminates common JWT vulnerabilities
  • Versioned protocols - Clear cryptographic versioning
  • Standardized payload structure - Consistent token format

PASETO vs JWT Security Comparison

AspectJWTPASETO
Algorithm ChoiceFlexible (often too flexible)Restricted to secure options
Header VulnerabilitiesYes (algorithm confusion)No (version-based)
Implementation ComplexityHighLow
Default SecurityWeakStrong

PASETO Versions and Modes

PASETO defines versions and purposes:

  • v2: Modern (recommended) - uses Ed25519 and XChaCha20
  • v4: Even more modern - uses Ed25519 and XChaCha20 with different primitives
  • local: Symmetric encryption (shared secret)
  • public: Asymmetric signing (public/private key pairs)

Java Implementation with paseto-java

1. Dependencies Setup

<!-- Maven -->
<dependencies>
<dependency>
<groupId>dev.paseto</groupId>
<artifactId>jpaseto-core</artifactId>
<version>0.7.0</version>
</dependency>
<dependency>
<groupId>dev.paseto</groupId>
<artifactId>jpaseto-impl</artifactId>
<version>0.7.0</version>
<scope>runtime</scope>
</dependency>
<groupId>org.bouncycastle</groupId>
<artifactId>bcprov-jdk18on</artifactId>
<version>1.78</version>
</dependencies>
// Gradle
implementation 'dev.paseto:jpaseto-core:0.7.0'
runtimeOnly 'dev.paseto:jpaseto-impl:0.7.0'
implementation 'org.bouncycastle:bcprov-jdk18on:1.78'

2. Local (Symmetric) Tokens

Token Creation and Encryption:

import dev.paseto.jpaseto.*;
import dev.paseto.jpaseto.lang.Keys;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.Date;
@Service
public class PasetoLocalTokenService {
private final byte[] sharedSecret;
public PasetoLocalTokenService(@Value("${paseto.shared.secret}") String base64Secret) {
this.sharedSecret = java.util.Base64.getDecoder().decode(base64Secret);
}
public String createUserToken(String userId, String username, List<String> roles) {
Instant now = Instant.now();
Instant expiration = now.plus(24, ChronoUnit.HOURS);
return Pasetos.V2.LOCAL.builder()
.setSharedSecret(sharedSecret)
.setIssuer("my-application")
.setIssuedAt(now)
.setExpiration(expiration)
.setSubject(userId)
.setAudience("my-app-audience")
.claim("username", username)
.claim("roles", roles)
.claim("token_type", "access")
.compact();
}
// Example usage
public String generateAuthToken(User user) {
return createUserToken(
user.getId(),
user.getUsername(),
user.getRoles()
);
}
}

Token Verification and Decryption:

@Service
public class PasetoLocalTokenVerifier {
private final PasetoParser parser;
public PasetoLocalTokenVerifier(byte[] sharedSecret) {
this.parser = Pasetos.parserBuilder()
.setSharedSecret(sharedSecret)
.requireIssuer("my-application")
.requireAudience("my-app-audience")
.require("token_type", "access")
.build();
}
public Paseto verifyToken(String token) {
try {
return parser.parse(token);
} catch (PasetoException e) {
throw new SecurityException("Invalid token: " + e.getMessage(), e);
}
}
public String getUserId(String token) {
return verifyToken(token).getSubject();
}
public UserClaims extractUserClaims(String token) {
Paseto parsed = verifyToken(token);
return new UserClaims(
parsed.getSubject(),
parsed.getClaim("username", String.class),
parsed.getClaim("roles", List.class)
);
}
public record UserClaims(String userId, String username, List<String> roles) {}
}

3. Public (Asymmetric) Tokens

Key Pair Generation:

import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.util.Base64;
@Component
public class PasetoKeyService {
public KeyPair generateKeyPair() throws Exception {
KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("Ed25519");
return keyPairGenerator.generateKeyPair();
}
public String exportPublicKey(KeyPair keyPair) {
return Base64.getEncoder().encodeToString(keyPair.getPublic().getEncoded());
}
public String exportPrivateKey(KeyPair keyPair) {
return Base64.getEncoder().encodeToString(keyPair.getPrivate().getEncoded());
}
}

Token Signing (Server Side):

@Service
public class PasetoPublicTokenService {
private final PrivateKey privateKey;
public PasetoPublicTokenService(@Value("${paseto.private.key}") String base64PrivateKey) 
throws Exception {
byte[] keyBytes = Base64.getDecoder().decode(base64PrivateKey);
KeyFactory keyFactory = KeyFactory.getInstance("Ed25519");
PKCS8EncodedKeySpec keySpec = new PKCS8EncodedKeySpec(keyBytes);
this.privateKey = keyFactory.generatePrivate(keySpec);
}
public String createSignedToken(User user, String tokenType, Duration validity) {
Instant now = Instant.now();
return Pasetos.V2.PUBLIC.builder()
.setPrivateKey(privateKey)
.setIssuer("auth-service")
.setIssuedAt(now)
.setExpiration(now.plus(validity))
.setSubject(user.getId())
.claim("username", user.getUsername())
.claim("email", user.getEmail())
.claim("roles", user.getRoles())
.claim("token_type", tokenType)
.claim("version", "1.0")
.compact();
}
public String createAccessToken(User user) {
return createSignedToken(user, "access", Duration.ofHours(1));
}
public String createRefreshToken(User user) {
return createSignedToken(user, "refresh", Duration.ofDays(30));
}
}

Token Verification (Client/Resource Server):

@Service 
public class PasetoPublicTokenVerifier {
private final PasetoParser parser;
public PasetoPublicTokenVerifier(PublicKey publicKey) {
this.parser = Pasetos.parserBuilder()
.setPublicKey(publicKey)
.requireIssuer("auth-service")
.build();
}
public Paseto verifyAndParse(String token) {
try {
return parser.parse(token);
} catch (ExpiredPasetoException e) {
throw new TokenExpiredException("Token has expired", e);
} catch (PasetoException e) {
throw new InvalidTokenException("Invalid token signature", e);
}
}
public boolean isValid(String token) {
try {
verifyAndParse(token);
return true;
} catch (InvalidTokenException | TokenExpiredException e) {
return false;
}
}
}

Spring Security Integration

1. PASETO Authentication Filter

@Component
public class PasetoAuthenticationFilter extends OncePerRequestFilter {
private final PasetoPublicTokenVerifier tokenVerifier;
private final UserDetailsService userDetailsService;
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
String authHeader = request.getHeader("Authorization");
if (authHeader != null && authHeader.startsWith("Bearer ")) {
String token = authHeader.substring(7);
try {
Paseto paseto = tokenVerifier.verifyAndParse(token);
if ("access".equals(paseto.getClaim("token_type"))) {
String username = paseto.getClaim("username", String.class);
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
UsernamePasswordAuthenticationToken authentication = 
new UsernamePasswordAuthenticationToken(
userDetails, null, userDetails.getAuthorities());
authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authentication);
}
} catch (InvalidTokenException e) {
SecurityContextHolder.clearContext();
}
}
filterChain.doFilter(request, response);
}
}

2. Security Configuration

@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final PasetoAuthenticationFilter pasetoAuthenticationFilter;
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf.disable())
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(authz -> authz
.requestMatchers("/api/auth/**").permitAll()
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.requestMatchers("/api/**").authenticated()
.anyRequest().permitAll()
)
.addFilterBefore(pasetoAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
}

Advanced PASETO Patterns

1. Token Blacklisting Service

@Service
@Slf4j
public class TokenBlacklistService {
private final Cache<String, Instant> blacklistedTokens;
public TokenBlacklistService() {
this.blacklistedTokens = Caffeine.newBuilder()
.expireAfterWrite(24, TimeUnit.HOURS)
.maximumSize(10_000)
.build();
}
public void blacklistToken(String token, Instant expiresAt) {
blacklistedTokens.put(token, expiresAt);
log.info("Token blacklisted, expires at: {}", expiresAt);
}
public boolean isBlacklisted(String token) {
return blacklistedTokens.getIfPresent(token) != null;
}
public void cleanup() {
blacklistedTokens.cleanUp();
}
}

2. Secure Token Claims Validation

@Component
public class TokenClaimsValidator {
public void validateAccessTokenClaims(Paseto paseto) {
Instant now = Instant.now();
// Validate expiration
if (paseto.getExpiration().isBefore(now)) {
throw new TokenExpiredException("Token expired");
}
// Validate not before
if (paseto.getNotBefore() != null && paseto.getNotBefore().isAfter(now)) {
throw new InvalidTokenException("Token not yet valid");
}
// Validate custom claims
String tokenType = paseto.getClaim("token_type", String.class);
if (!"access".equals(tokenType)) {
throw new InvalidTokenException("Invalid token type: " + tokenType);
}
List<String> roles = paseto.getClaim("roles", List.class);
if (roles == null || roles.isEmpty()) {
throw new InvalidTokenException("No roles assigned");
}
}
public void validateRefreshTokenClaims(Paseto paseto) {
String tokenType = paseto.getClaim("token_type", String.class);
if (!"refresh".equals(tokenType)) {
throw new InvalidTokenException("Invalid refresh token");
}
}
}

3. Token Service Factory

@Component
public class PasetoTokenServiceFactory {
private final Map<TokenType, TokenService> services;
public PasetoTokenServiceFactory(KeyPair keyPair, byte[] sharedSecret) {
this.services = Map.of(
TokenType.ACCESS, new AccessTokenService(keyPair.getPrivate()),
TokenType.REFRESH, new RefreshTokenService(keyPair.getPrivate()),
TokenType.API_KEY, new ApiKeyTokenService(sharedSecret)
);
}
public TokenService getService(TokenType type) {
return services.get(type);
}
public enum TokenType {
ACCESS, REFRESH, API_KEY
}
public interface TokenService {
String createToken(User user, Duration validity);
Paseto verifyToken(String token);
}
private static class AccessTokenService implements TokenService {
private final PrivateKey privateKey;
AccessTokenService(PrivateKey privateKey) {
this.privateKey = privateKey;
}
@Override
public String createToken(User user, Duration validity) {
return Pasetos.V2.PUBLIC.builder()
.setPrivateKey(privateKey)
.setSubject(user.getId())
.setExpiration(Instant.now().plus(validity))
.claim("username", user.getUsername())
.claim("roles", user.getRoles())
.claim("token_type", "access")
.compact();
}
@Override
public Paseto verifyToken(String token) {
// Implementation for verification
return null;
}
}
}

Best Practices for PASETO Implementation

1. Secure Key Management

@Configuration
public class PasetoKeyConfig {
@Bean
@Profile("!test")
public KeyPair productionKeyPair() throws Exception {
// In production, load from secure storage (HashiCorp Vault, AWS KMS, etc.)
String privateKeyBase64 = System.getenv("PASETO_PRIVATE_KEY");
byte[] privateKeyBytes = Base64.getDecoder().decode(privateKeyBase64);
KeyFactory keyFactory = KeyFactory.getInstance("Ed25519");
PKCS8EncodedKeySpec keySpec = new PKCS8EncodedKeySpec(privateKeyBytes);
PrivateKey privateKey = keyFactory.generatePrivate(keySpec);
// Derive public key from private key
KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("Ed25519");
keyPairGenerator.initialize(256);
KeyPair keyPair = keyPairGenerator.generateKeyPair();
return new KeyPair(keyPair.getPublic(), privateKey);
}
@Bean
@Profile("test")
public KeyPair testKeyPair() throws Exception {
// For testing, generate a new key pair
KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("Ed25519");
return keyPairGenerator.generateKeyPair();
}
}

2. Comprehensive Token Validation

@Service
public class ComprehensiveTokenValidator {
private final PasetoPublicTokenVerifier tokenVerifier;
private final TokenClaimsValidator claimsValidator;
private final TokenBlacklistService blacklistService;
public TokenValidationResult validateToken(String token) {
try {
// Check blacklist first
if (blacklistService.isBlacklisted(token)) {
return TokenValidationResult.blacklisted();
}
// Verify cryptographic signature
Paseto paseto = tokenVerifier.verifyAndParse(token);
// Validate claims
String tokenType = paseto.getClaim("token_type", String.class);
if ("access".equals(tokenType)) {
claimsValidator.validateAccessTokenClaims(paseto);
} else if ("refresh".equals(tokenType)) {
claimsValidator.validateRefreshTokenClaims(paseto);
}
return TokenValidationResult.valid(paseto);
} catch (TokenExpiredException e) {
return TokenValidationResult.expired();
} catch (InvalidTokenException e) {
return TokenValidationResult.invalid(e.getMessage());
}
}
public record TokenValidationResult(boolean valid, String status, Paseto paseto, String message) {
public static TokenValidationResult valid(Paseto paseto) {
return new TokenValidationResult(true, "VALID", paseto, null);
}
public static TokenValidationResult expired() {
return new TokenValidationResult(false, "EXPIRED", null, "Token expired");
}
public static TokenValidationResult invalid(String message) {
return new TokenValidationResult(false, "INVALID", null, message);
}
public static TokenValidationResult blacklisted() {
return new TokenValidationResult(false, "BLACKLISTED", null, "Token revoked");
}
}
}

Conclusion

PASETO tokens provide a significant security improvement over traditional JWT for Java applications. By eliminating algorithmic confusion attacks, simplifying cryptographic choices, and providing sensible defaults, PASETO reduces the attack surface and implementation complexity.

Key advantages for Java developers:

  • Eliminates common JWT vulnerabilities like algorithm confusion
  • Simpler API with fewer security decisions required
  • Strong cryptographic defaults using modern algorithms like Ed25519
  • Versioned protocol ensuring forward compatibility
  • Excellent Spring Security integration capabilities

For new projects requiring stateless tokens, PASETO should be the preferred choice. For existing JWT-based systems, consider migrating to PASETO during security reviews or major version updates to benefit from its enhanced security posture.

The Java ecosystem's support for PASETO through libraries like jpaseto makes adoption straightforward, providing a secure foundation for modern authentication systems.

Leave a Reply

Your email address will not be published. Required fields are marked *


Macro Nepal Helper