Architectural Alignment for Token Refresh
Ktor’s JWT authentication plugin is designed as a stateless validator. It does not—and should not—automatically invoke a developer-supplied refresh callback upon encountering an expired token. The responsibility for token rotation resides with the client, while the server provides the mechanism to exchange a refresh token for a new access token.
Why Automatic Invocation is Not Built-In
Integrating a refresh handler directly into the JWT validation pipeline would introduce several architectural and security risks:
- Violation of Statelessness: JWTs are intended to be verified without database lookups. Refreshing a token typically requires a stateful check (e.g., verifying a refresh token against a database or Redis), which would degrade the performance of every authenticated request.
- Security Guarantees: Automatic refreshing could inadvertently extend a session indefinitely without re-authentication, bypassing security policies regarding maximum session lifetimes.
- Pipeline Complexity: Ktor's authentication pipeline is designed to either succeed (providing a Principal) or fail (triggering a 401). Injecting a side-effect that modifies the request or issues new credentials mid-pipeline would break backward compatibility and make the request lifecycle unpredictable.
Recommended Implementation Pattern
To achieve interoperability between the JWT plugin and a refresh mechanism, use a decoupled approach. This ensures the authentication pipeline remains lean while providing a clear path for token rotation.
- Stateless Validation: Configure the
jwt provider to validate the access token. If expired, Ktor naturally returns a 401 Unauthorized.
- Dedicated Refresh Endpoint: Create a public route (e.g.,
/auth/refresh) that is not protected by the JWT plugin. This endpoint should validate a long-lived refresh token (stored securely, such as in an HttpOnly cookie).
- Client-Side Rotation: The client intercepts the 401 response, calls the refresh endpoint, updates its local storage with the new JWT, and retries the original request.
Verification Steps
To verify this flow in a Ktor environment (assuming Ktor 2.x or 3.x), ensure the following structure:
// 1. Protected Route
authenticate("auth-jwt") {
get("/secure") { call.respond(TextContent("Success", ContentType.Text.Plain)) }
}
// 2. Public Refresh Route
post("/refresh") {
val refreshToken = call.request.cookies["refresh_token"]
if (isValid(refreshToken)) {
val newJwt = generateJwt()
call.respond(mapOf("accessToken" to newJwt))
} else {
call.respond(HttpStatusCode.Forbidden)
}
}
Diagnostic Detail Required: Are you attempting to implement a "silent refresh" where the server automatically attaches a new token to the response header of a failed request, or are you following the standard client-initiated rotation flow?