Ktor JWT Authentication with a Minimal Revocation List: An Architecture Note
Implement a lightweight, secure JWT auth layer in Ktor using the built‑in Authentication plugin and a small relational revocation table. The design balances statelessness, scalability, and revocation control, with clear trust boundaries, operational hooks, and failure‑mode guidance.
19 Aug 2026, 00:29 UTC

Problem Statement
When a Ktor API relies on JWTs for stateless authentication, tokens can never be revoked once issued. In many production scenarios—password resets, compromised accounts, or policy changes—revocation is essential. This article shows how to add a lightweight revocation layer without compromising Ktor’s performance or scalability.
Functional Requirements
- Validate JWT signature, issuer, audience, and expiration using Ktor’s
Authenticationplugin. - Support revocation of individual tokens identified by their
jticlaim. - Keep the revocation check fast enough for high‑throughput APIs.
- Expose only two public endpoints: API calls (HTTPS) and an admin endpoint for revocation management.
- Provide audit logging for authentication failures and revocation hits.
Minimal Architecture
The design keeps Ktor’s core responsibilities untouched and adds a single interceptor that consults a relational revocation table. The table is tiny, indexed by jti, and accessed through a HikariCP pool.
Component Diagram (textual)
┌───────────────────────┐
│ Ktor Application │
│ (HTTPS API + Admin) │
└────────────┬──────────┘
│
▼
┌───────────────────────┐
│ Authentication Plugin│ ← JWT validation
└────────────┬──────────┘
│
▼
┌───────────────────────┐
│ Revocation Interceptor │ ← JTI lookup
└────────────┬──────────┘
│
▼
┌───────────────────────┐
│ HikariCP + DB │ ← Revocation table
└───────────────────────┘
Database Schema
| Column | Type | Notes |
|---|---|---|
| jti | VARCHAR(64) PRIMARY KEY | JWT ID claim |
| revoked_at | TIMESTAMP | When revocation was recorded |
| reason | VARCHAR(255) | Optional revocation reason |
| expires_at | TIMESTAMP | Optional expiry of revocation entry (for cleanup) |
Trust & Data Boundaries
- External Interface: Only HTTPS endpoints are exposed. TLS terminates at the application or a load balancer; Ktor trusts the TLS layer for client authenticity.
- Authentication Boundary: The
Authenticationplugin validates token integrity. It never reads the revocation table. - Revocation Boundary: The revocation interceptor runs after authentication, ensuring the token is already trusted before checking the revocation list.
- Admin Boundary: The admin endpoints are protected by a separate API key or mutual TLS, preventing unauthorized revocation operations.
Implementation Details
1. Configure the Authentication Plugin
fun Application.configureAuth() {
install(Authentication) {
jwt("auth-jwt") {
realm = "ktor.io"
verifier(
JWT
.require(Algorithm.HMAC256(environment.config.property("jwt.secret").getString()))
.withIssuer("ktor.io")
.withAudience("ktor-api")
.build()
)
validate { credential ->
// Basic claim checks – issuer, audience, expiration are handled automatically
UserIdPrincipal(credential.payload.getClaim("sub").asString())
}
}
}
}
Run on the application JVM with --module=auth-jwt as the authentication name. The plugin automatically rejects tampered or expired tokens.
2. Add the Revocation Interceptor
fun Application.configureRevocation() {
val dataSource = HikariDataSource(HikariConfig().apply {
jdbcUrl = environment.config.property("database.jdbcUrl").getString()
driverClassName = "org.postgresql.Driver"
maximumPoolSize = 10
isAutoCommit = false
})
install(StatusPages) {
exception<RevokedTokenException> { cause ->
call.respond(HttpStatusCode.Forbidden, mapOf("error" to "token revoked"))
}
}
intercept(ApplicationCallPipeline.Features) {
val principal = call.authentication.principal<UserIdPrincipal>()
if (principal != null) {
val jti = call.request.headers["X-JTI"] ?: call.request.headers["Authorization"].substringAfter("Bearer ").split(".")[1]
// Decode payload to get jti if not passed via header
val payload = JWT.decode(call.request.headers["Authorization"].substringAfter("Bearer ")
val jtiClaim = payload.getClaim("jti")
if (jtiClaim != null) {
val query = "SELECT 1 FROM revocations WHERE jti = ?"
val isRevoked = dataSource.connection.use { conn ->
conn.prepareStatement(query).use { stmt ->
stmt.setString(1, jtiClaim.asString())
stmt.executeQuery().next()
}
}
if (isRevoked) throw RevokedTokenException()
}
}
}
}
class RevokedTokenException : RuntimeException()
Key points:
- The interceptor runs after authentication, ensuring only valid tokens reach this step.
- It performs a single‑row lookup, O(1) on the indexed
jticolumn. - All operations run within the same request context; no state is mutated.
3. Admin Endpoint for Revocation
routing {
authenticate("admin-api-key") {
post("/admin/revoke") {
val jti = call.receive<String>() // Expect JSON body with jti
val insert = "INSERT INTO revocations (jti, revoked_at, reason) VALUES (?, NOW(), ?)"
dataSource.connection.use { conn ->
conn.prepareStatement(insert).use { stmt ->
stmt.setString(1, jti)
stmt.setString(2, "admin request")
stmt.executeUpdate()
}
}
call.respond(HttpStatusCode.OK, mapOf("status" to "revoked"))
}
}
}
Protect this route with an API key or mutual TLS. The revocation table is the only stateful element, so it can be replicated or sharded if needed.
Operational Checks
- Health Endpoint: Expose
/healththat verifies DB connectivity and plugin readiness. - Audit Logging: Log each
RevokedTokenExceptionwith timestamp, client IP, and JTI. Avoid logging the raw token. - Metrics: Record histogram of revocation lookup latency and counter of revocation hits. Use Prometheus or OpenTelemetry exporters.
- Backup Strategy: Schedule nightly WAL‑based backups of the revocation table. A snapshot restores the revocation state instantly.
Failure Modes & Redesign Triggers
- Revocation Lookup Bottleneck: If latency > 5 ms under load, consider caching recent JTI lookups in Redis or adding a read‑replica pool.
- Replication Lag: In a multi‑node deployment, if a token is revoked on one node but accepted on another, the revocation store must be replicated (e.g., via PostgreSQL logical replication) or a distributed cache must be used.
- Data Loss: Loss of revocation data allows stale tokens to be accepted. Ensure backups are retained for at least 30 days.
- Algorithm Weakness: If the signing algorithm drops below RS256/HS512, the entire authentication layer becomes insecure. Upgrade the verifier accordingly.
- Token Payload Growth: Adding sensitive data to the JWT (e.g., PII) increases the risk if a token is intercepted. Enforce minimal payloads.
Practical Verification Steps
- Deploy the application locally:
./gradlew run -Pjwt.secret=supersecret -Pdatabase.jdbcUrl=jdbc:postgresql://localhost:5432/ktor - Generate a test JWT (HS256) with
jti=abc123andsub=alice. - Send a request to a protected endpoint. Expect 200 OK.
- Revoke
abc123via the admin endpoint. - Repeat the request; expect 403 Forbidden and a log entry for
RevokedTokenException. - Run
pgbenchagainst the revocation table to confirm lookup latency remains <5 ms.
Conclusion
By leveraging Ktor’s Authentication plugin and adding a lightweight revocation interceptor, you achieve a stateless, scalable authentication layer that still supports token revocation. The design keeps the core logic untouched, introduces minimal state, and provides clear operational hooks for monitoring and auditing. Monitor the revocation lookup latency and replication health; if these thresholds are breached, consider moving to a distributed cache or sharding the revocation table.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.