JWT Integration
CoSec uses the Auth0 java-jwt library to create and verify JSON Web Tokens. The integration is encapsulated in the cosec-jwt module, which provides JwtTokenConverter (issue tokens) and JwtTokenVerifier (verify and extract principals). Spring Boot auto-configuration wires everything together.
Token Lifecycle
Token Validity Defaults
| Token Type | Default Validity | Configurable Via |
|---|---|---|
| Access Token | 10 minutes | cosec.jwt.token-validity.access |
| Refresh Token | 7 days | cosec.jwt.token-validity.refresh |
These defaults are defined in JwtProperties:
data class TokenValidity(
var access: Duration = Duration.ofMinutes(10),
var refresh: Duration = Duration.ofDays(7)
)Supported Algorithms
The auto-configuration supports three HMAC algorithms, selected via cosec.jwt.algorithm:
| Value | Algorithm | Javadoc |
|---|---|---|
HMAC256 (default) | HS256 | Algorithm.HMAC256(secret) |
HMAC384 | HS384 | Algorithm.HMAC384(secret) |
HMAC512 | HS512 | Algorithm.HMAC512(secret) |
JWT Claims Structure
JwtTokenConverter builds a JWT with the following claims structure for access tokens:
{
"jti": "<generated-unique-id>",
"sub": "<principal.id>",
"iat": 1684000000,
"exp": 1684000600,
"policies": ["policy-id-1", "policy-id-2"],
"roles": ["admin", "user"],
"attributes": {"key": "value"},
"tenantId": "tenant-123"
}Key mappings:
sub(Subject): Set toprincipal.id-- the unique user identifierjti(JWT ID): Generated by anIdGenerator(default: UUID). Used for token revocation (see Token Revocation (Logout)) and refresh token bindingpolicies:PolicyCapable.POLICY_KEYclaim -- list of policy IDs assigned to the principal (written only when non-empty)roles:RoleCapable.ROLE_KEYclaim -- list of role IDs (written only when non-empty)attributes:CoSecPrincipal::attributes.nameclaim -- arbitrary key-value metadata (written only when non-empty)tenantId:Tenant.TENANT_ID_KEYclaim -- present only when the principal implementsTenantCapable
Refresh tokens have a simpler structure:
{
"jti": "<refresh-token-id>",
"sub": "<access-token-id>",
"iat": 1684000000,
"exp": 1685209600
}The refresh token's sub claim is set to the access token's jti, creating a binding between the two tokens.
Key Classes
JwtTokenConverter
JwtTokenConverter implements TokenConverter and converts a CoSecPrincipal into a CompositeToken:
class JwtTokenConverter(
private val idGenerator: IdGenerator,
private val algorithm: Algorithm,
private val accessTokenValidity: Duration = Duration.ofMinutes(10),
private val refreshTokenValidity: Duration = Duration.ofDays(7)
) : TokenConverterJwtTokenVerifier
JwtTokenVerifier implements TokenVerifier and provides:
verify(AccessToken): Validates signature, checks expiry, extractsTokenPrincipalrefresh(CompositeToken): Verifies the refresh token, verifies the access token's signature (its expiry may have passed), ensures itssubmatches the access token'sjti, then extracts the principal from the (possibly expired) access token
Jwts Utility
Jwts provides helper functions:
decode(token): StripsBearerprefix and decodes the JWT without verificationtoPrincipal(decodedJWT): Extracts all claims and constructs aTokenPrincipal(orTokenTenantPrincipalwhentenantIdis present)removeBearerPrefix(): String extension that removes the"Bearer "prefix if present
Architecture Diagrams
Token Creation Flow
sequenceDiagram
autonumber
participant Auth as TokenCompositeAuthentication
participant CA as CompositeAuthentication
participant AP as AuthenticationProvider
participant Impl as Authentication Impl
participant Conv as JwtTokenConverter
participant JWT as JWT.create()
Auth->>CA: authenticate(credentials)
CA->>AP: getRequired(credentialsType)
AP-->>CA: Authentication instance
CA->>Impl: authenticate(credentials)
Impl-->>Auth: CoSecPrincipal
Auth->>Conv: toToken(principal)
Conv->>JWT: create access token (sub=policies=roles=tenantId)
JWT-->>Conv: signed access token string
Conv->>JWT: create refresh token (sub=accessTokenId)
JWT-->>Conv: signed refresh token string
Conv-->>Auth: CompositeToken(accessToken, refreshToken)Token Verification Flow
flowchart TD
A["Incoming AccessToken"] --> B["removeBearerPrefix()"]
B --> C["jwtVerifier.verify(token)"]
C --> D{"Verification result"}
D -->|"TokenExpiredException"| E["throw TokenExpiredException"]
D -->|"Other Exception"| F["throw TokenVerificationException"]
D -->|"Valid DecodedJWT"| N{"revoked (jti in TokenStore)?"}
N -->|"yes"| R["throw TokenRevokedException (401)"]
N -->|"no"| G["Jwts.toPrincipal(decodedJWT)"]
G --> H["Extract sub, policies, roles, attributes"]
H --> I{"tenantId claim present?"}
I -->|"yes"| J["return TokenTenantPrincipal"]
I -->|"no"| K["return TokenPrincipal"]
style A fill:#2d333b,stroke:#6d5dfc,color:#e6edf3
style B fill:#2d333b,stroke:#6d5dfc,color:#e6edf3
style C fill:#2d333b,stroke:#6d5dfc,color:#e6edf3
style D fill:#2d333b,stroke:#6d5dfc,color:#e6edf3
style E fill:#2d333b,stroke:#6d5dfc,color:#e6edf3
style F fill:#2d333b,stroke:#6d5dfc,color:#e6edf3
style G fill:#2d333b,stroke:#6d5dfc,color:#e6edf3
style H fill:#2d333b,stroke:#6d5dfc,color:#e6edf3
style I fill:#2d333b,stroke:#6d5dfc,color:#e6edf3
style J fill:#2d333b,stroke:#6d5dfc,color:#e6edf3
style K fill:#2d333b,stroke:#6d5dfc,color:#e6edf3
style N fill:#2d333b,stroke:#6d5dfc,color:#e6edf3
style R fill:#2d333b,stroke:#6d5dfc,color:#e6edf3Refresh Token Flow
sequenceDiagram
autonumber
participant Client
participant Verifier as JwtTokenVerifier
participant JWT as JWT.require()
participant Jwts as Jwts
Client->>Verifier: refresh(CompositeToken)
Verifier->>JWT: verify(refreshToken)
JWT-->>Verifier: DecodedJWT (refresh)
Verifier->>Jwts: decode(accessToken) -- signature verified next, expiry not re-checked
Jwts-->>Verifier: DecodedJWT (access, possibly expired)
Verifier->>Verifier: verifyAccessTokenSignature(accessJWT)
Verifier->>Verifier: require(refresh.sub == access.jti)
Verifier->>Jwts: toPrincipal(accessJWT)
Jwts-->>Verifier: TokenPrincipal
Verifier-->>Client: TokenPrincipalToken Revocation (Logout)
JWTs are stateless by default -- deleting the token on the client does not invalidate it on the server. CoSec closes this gap with an opt-in revocation mechanism keyed by the token's jti claim.
What It Does
TokenRevoker.revoke(accessToken)verifies the token and records itsjtiin a revocation store. Call it from your own logout endpoint.- A revoked access token is rejected immediately with a
401-- every verification goes throughRevocableTokenVerifier, which checks the store before accepting the token. - The refresh token is bound to the access token's
jti(itssubclaim), so refreshing a revoked token is rejected as well. - The revocation entry is kept for the refresh token validity (
cosec.jwt.token-validity.refresh), so it never expires before the bound refresh token does.
Logout stays idempotent when the token is already invalid -- catch and ignore the verification failure:
@PostMapping("/logout")
fun logout(@RequestHeader(HttpHeaders.AUTHORIZATION) authorization: String): ResponseEntity<Void> {
try {
tokenRevoker.revoke(SimpleAccessToken(authorization)) // the verifier strips the Bearer prefix
} catch (ignored: TokenVerificationException) {
// token already invalid or expired -- nothing left to revoke
}
return ResponseEntity.noContent().build()
}Note: revoke before the access token expires. An already-expired access token fails verification, and its still-valid bound refresh token can then no longer be killed through TokenRevoker.
How to Enable
cosec:
jwt:
token-revocation:
enabled: trueThis wires the Redis-backed CoCacheTokenStore, which requires the cosec-cocache dependency (the starter's cacheSupport Gradle feature) and a Redis connection:
dependencies {
implementation("me.ahoo.cosec:cosec-spring-boot-starter") {
capabilities {
requireCapability("me.ahoo.cosec:cosec-spring-boot-starter-cache-support")
}
}
}TokenStore SPI
Revocation storage is pluggable through the TokenStore SPI:
- Default
NoOp-- stateless and a no-op. Upgrading CoSec changes nothing until you opt in, so the stateless default behavior is preserved. CoCacheTokenStore-- the ready-made Redis-backed implementation (two-level CoCache: local + Redis), wired automatically when the property is enabled and cosec-cocache is on the classpath.- Custom -- provide your own
TokenStorebean to store revocations anywhere else.
Operational Notes
- Inject mode: downstream services that inject the security context from gateway headers do not verify the JWT signature, so they cannot check revocation either. Enforcement happens at the verifying edge (the gateway).
- Propagation: CoCache evicts local entries via Redis pub/sub, so revocation takes effect on all instances near-instantly when the cluster is healthy. In the worst case, propagation is bounded by the local cache TTL (default 30 seconds, configurable via
cosec.authorization.cache.token.*). - Fail-open on Redis outage: with CoCache's default
strictFailure=false, an unreachable Redis makesisRevokedfall back tofalse(revoked tokens may authenticate again) and revoke writes are dropped. Deployments that prefer fail-closed behavior can setcocache.redis.strict-failure=true.
Spring Boot Auto-Configuration
CoSecJwtAutoConfiguration is activated when:
cosec.enabled=true(default)cosec.jwt.enabled=true(default)JwtTokenConverteris on the classpath
It registers five beans:
| Bean | Type | Purpose |
|---|---|---|
cosecTokenAlgorithm | Algorithm | HMAC algorithm from config |
cosecTokenConverter | TokenConverter | Creates JWT tokens |
cosecTokenStore | TokenStore | Revocation store (NoOp unless the cache-backed one is wired, see Token Revocation (Logout)) |
cosecJwtTokenVerifier | TokenVerifier | Verifies JWT tokens and rejects revoked ones |
cosecTokenRevoker | TokenRevoker | Revokes tokens for logout |
When authentication is also enabled, it additionally registers TokenCompositeAuthentication which chains credential-based authentication with token issuance.
Configuration Example
cosec:
jwt:
enabled: true
algorithm: HMAC256
secret: your-secret-key-must-be-long-enough
token-validity:
access: 10m
refresh: 7d
token-revocation:
enabled: false # opt-in logout; true wires the Redis-backed CoCacheTokenStoreThe revocation local cache is tuned under cosec.authorization.cache.token.* (default: 30s expire-after-write, 100k entries -- also the worst-case logout propagation window across instances).
References
- JwtTokenConverter.kt:42 - JWT token creation with claims
- JwtTokenVerifier.kt:37 - JWT verification and principal extraction
- Jwts.kt:44 - JWT utility functions (decode, toPrincipal, removeBearerPrefix)
- CoSecJwtAutoConfiguration.kt:52 - Spring Boot auto-configuration
- JwtProperties.kt:28 - Configuration properties
- TokenStore.kt:33 - Token revocation store SPI
- CoCacheTokenStore.kt:29 - Redis-backed revocation store
Related Pages
- Authentication System - How JWT plugs into the provider registry
- Token Management - Token hierarchy and principal types
- Social Authentication - OAuth-based authentication alternative
- Authorization Flow - How token claims drive authorization decisions