In the previous tutorial, you added JWT authentication. Users can register, login, and access protected routes. But the implementation was basic. There were no refresh tokens, no password validation, and no logout.

In this tutorial, you will build a complete authentication flow. You will add refresh tokens with rotation, strong password validation, email validation, and a logout endpoint that revokes tokens.

What We Will Build

Here is the complete auth flow:

1. Register → Validate input → Create user → Return access + refresh tokens
2. Login → Verify credentials → Return access + refresh tokens
3. Access API → Send access token → Server validates → Return data
4. Refresh → Send refresh token → Revoke old → Return new tokens
5. Logout → Revoke all refresh tokens → User must login again

Refresh Tokens — Why You Need Them

Access tokens expire after one hour. Without refresh tokens, users must login again every hour. That is a bad experience.

Refresh tokens solve this:

  • Access token: Short-lived (1 hour). Used for API requests.
  • Refresh token: Long-lived (30 days). Used only to get new access tokens.

When the access token expires, the client sends the refresh token to get a new pair of tokens. The old refresh token is revoked. This is called token rotation.

Access Token Expired?
Send Refresh Token → POST /api/auth/refresh
Server validates → Revokes old refresh token → Returns new access + refresh tokens
Client stores new tokens → Continues using the API

Database Migration

Add a migration for the role column and the refresh_tokens table:

-- V6__add_role_and_refresh_tokens.sql

ALTER TABLE users ADD COLUMN role VARCHAR(20) NOT NULL DEFAULT 'user';

CREATE TABLE IF NOT EXISTS refresh_tokens (
    id INT AUTO_INCREMENT PRIMARY KEY,
    user_id INT NOT NULL,
    token VARCHAR(500) NOT NULL UNIQUE,
    expires_at VARCHAR(50) NOT NULL,
    revoked BOOLEAN NOT NULL DEFAULT FALSE,
    CONSTRAINT fk_refresh_tokens_user FOREIGN KEY (user_id) REFERENCES users(id)
);

The role column lets you add role-based access control later. The refresh_tokens table stores tokens in the database so you can revoke them.

Update the Table Definitions

Add the new table and the role column to your Exposed definitions:

// Users table - now with role
object Users : Table("users") {
    val id = integer("id").autoIncrement()
    val name = varchar("name", 100)
    val email = varchar("email", 255).uniqueIndex()
    val passwordHash = varchar("password_hash", 255).default("")
    val role = varchar("role", 20).default("user")

    override val primaryKey = PrimaryKey(id)
}

// Refresh tokens table
object RefreshTokens : Table("refresh_tokens") {
    val id = integer("id").autoIncrement()
    val userId = integer("user_id").references(Users.id)
    val token = varchar("token", 500).uniqueIndex()
    val expiresAt = varchar("expires_at", 50)
    val revoked = bool("revoked").default(false)

    override val primaryKey = PrimaryKey(id)
}

Updated Models

The token response now includes both access and refresh tokens:

// Token response after login/register
@Serializable
data class TokenResponse(
    val accessToken: String,
    val refreshToken: String,
    val expiresIn: Long = 3600
)

// Refresh token request
@Serializable
data class RefreshTokenRequest(
    val refreshToken: String
)

// Profile response (includes role)
@Serializable
data class ProfileResponse(
    val id: Int,
    val name: String,
    val email: String,
    val role: String
)

Update JWT Configuration

Add role to the access token and a method to generate refresh tokens:

object JwtConfig {
    const val SECRET = "ktor-tutorial-secret-key-change-in-production"
    const val ISSUER = "ktor-tutorial"
    const val AUDIENCE = "ktor-tutorial-api"
    const val ACCESS_TOKEN_EXPIRATION_MS = 3_600_000L  // 1 hour
    const val REFRESH_TOKEN_EXPIRATION_MS = 2_592_000_000L // 30 days

    // Generate an access token with role
    fun generateAccessToken(userId: Int, email: String, role: String): String {
        return JWT.create()
            .withAudience(AUDIENCE)
            .withIssuer(ISSUER)
            .withClaim("userId", userId)
            .withClaim("email", email)
            .withClaim("role", role)
            .withExpiresAt(Date(System.currentTimeMillis() + ACCESS_TOKEN_EXPIRATION_MS))
            .sign(Algorithm.HMAC256(SECRET))
    }

    // Generate a refresh token (random UUID)
    fun generateRefreshToken(): String {
        return UUID.randomUUID().toString()
    }
}

The access token includes the role claim. This lets you check permissions without a database query.

The refresh token is a random UUID. It is not a JWT. It has no payload. You look it up in the database to validate it.

Refresh Token Repository

Create a repository to manage refresh tokens:

class RefreshTokenRepository {

    // Save a new refresh token
    suspend fun create(userId: Int, token: String): String = dbQuery {
        val expiresAt = Instant.now()
            .plusMillis(JwtConfig.REFRESH_TOKEN_EXPIRATION_MS)
            .toString()

        RefreshTokens.insert {
            it[RefreshTokens.userId] = userId
            it[RefreshTokens.token] = token
            it[RefreshTokens.expiresAt] = expiresAt
        }
        token
    }

    // Find a valid (not revoked, not expired) refresh token
    suspend fun findValidToken(token: String): RefreshTokenInfo? = dbQuery {
        RefreshTokens.selectAll()
            .where { (RefreshTokens.token eq token) and (RefreshTokens.revoked eq false) }
            .map { row ->
                RefreshTokenInfo(
                    id = row[RefreshTokens.id],
                    userId = row[RefreshTokens.userId],
                    token = row[RefreshTokens.token],
                    expiresAt = row[RefreshTokens.expiresAt]
                )
            }
            .singleOrNull()
            ?.takeIf { Instant.parse(it.expiresAt).isAfter(Instant.now()) }
    }

    // Revoke a specific refresh token
    suspend fun revoke(token: String): Boolean = dbQuery {
        RefreshTokens.update({ RefreshTokens.token eq token }) {
            it[revoked] = true
        } > 0
    }

    // Revoke all refresh tokens for a user (logout)
    suspend fun revokeAllForUser(userId: Int): Int = dbQuery {
        RefreshTokens.update({ RefreshTokens.userId eq userId }) {
            it[revoked] = true
        }
    }
}

Key points:

  • findValidToken checks both revoked and expiresAt
  • revoke marks a single token as revoked (used during rotation)
  • revokeAllForUser marks all tokens as revoked (used during logout)

Input Validation

Good APIs validate input before processing. Here are the validation rules:

// Email validation regex
private val EMAIL_REGEX = Regex("^[A-Za-z0-9+_.-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}$")

Password Validation

// Password must be at least 8 characters with at least one digit
if (request.password.length < 8) {
    call.respond(HttpStatusCode.BadRequest,
        ErrorResponse("Password must be at least 8 characters", 400))
    return@post
}

if (!request.password.any { it.isDigit() }) {
    call.respond(HttpStatusCode.BadRequest,
        ErrorResponse("Password must contain at least one digit", 400))
    return@post
}

Email Validation

if (!EMAIL_REGEX.matches(request.email)) {
    call.respond(HttpStatusCode.BadRequest,
        ErrorResponse("Invalid email format", 400))
    return@post
}

Name Validation

if (request.name.isBlank() || request.name.length < 2) {
    call.respond(HttpStatusCode.BadRequest,
        ErrorResponse("Name must be at least 2 characters", 400))
    return@post
}

Registration Endpoint

The updated registration endpoint validates input, creates the user, and returns both tokens:

post("/auth/register") {
    val request = call.receive<RegisterRequest>()

    // Validate name, email, password (see above)

    // Check if email already exists
    val existing = userRepository.findByEmail(request.email)
    if (existing != null) {
        call.respond(HttpStatusCode.Conflict,
            ErrorResponse("Email already registered", 409))
        return@post
    }

    // Register and generate tokens
    val user = userRepository.register(request)
    val accessToken = JwtConfig.generateAccessToken(user.id, user.email, user.role)
    val refreshToken = JwtConfig.generateRefreshToken()
    refreshTokenRepository.create(user.id, refreshToken)

    call.respond(HttpStatusCode.Created, TokenResponse(accessToken, refreshToken))
}

Login Endpoint

Login uses the same generic error message for both “user not found” and “wrong password”. This prevents attackers from discovering which emails are registered.

post("/auth/login") {
    val request = call.receive<LoginRequest>()

    // Generic error message - don't reveal which part failed
    val user = userRepository.verifyPassword(request.email, request.password)
    if (user == null) {
        call.respond(HttpStatusCode.Unauthorized,
            ErrorResponse("Invalid email or password", 401))
        return@post
    }

    val accessToken = JwtConfig.generateAccessToken(user.id, user.email, user.role)
    val refreshToken = JwtConfig.generateRefreshToken()
    refreshTokenRepository.create(user.id, refreshToken)

    call.respond(TokenResponse(accessToken, refreshToken))
}

Refresh Token Endpoint

The refresh endpoint implements token rotation:

  1. Find the refresh token in the database
  2. Revoke it (so it cannot be reused)
  3. Generate new access and refresh tokens
post("/auth/refresh") {
    val request = call.receive<RefreshTokenRequest>()

    val tokenInfo = refreshTokenRepository.findValidToken(request.refreshToken)
    if (tokenInfo == null) {
        call.respond(HttpStatusCode.Unauthorized,
            ErrorResponse("Invalid or expired refresh token", 401))
        return@post
    }

    // Revoke the old refresh token (rotation)
    refreshTokenRepository.revoke(request.refreshToken)

    // Generate new tokens
    val profile = userRepository.findProfileById(tokenInfo.userId)
    val newAccessToken = JwtConfig.generateAccessToken(profile.id, profile.email, profile.role)
    val newRefreshToken = JwtConfig.generateRefreshToken()
    refreshTokenRepository.create(profile.id, newRefreshToken)

    call.respond(TokenResponse(newAccessToken, newRefreshToken))
}

Why token rotation matters: If someone steals a refresh token and uses it, the real user’s next refresh attempt will fail (because the token was already used). This signals a security breach.

Logout Endpoint

Logout revokes all refresh tokens for the user. The access token will still work until it expires (up to 1 hour), but no new access tokens can be generated.

authenticate("auth-jwt") {
    post("/auth/logout") {
        val principal = call.principal<JWTPrincipal>()
        val userId = principal?.payload?.getClaim("userId")?.asInt()

        refreshTokenRepository.revokeAllForUser(userId!!)
        call.respond(HttpStatusCode.OK, mapOf("message" to "Logged out successfully"))
    }
}

Profile Endpoint

The /me endpoint now returns the profile with role:

authenticate("auth-jwt") {
    get("/auth/me") {
        val principal = call.principal<JWTPrincipal>()
        val userId = principal?.payload?.getClaim("userId")?.asInt()

        val profile = userRepository.findProfileById(userId!!)
        call.respond(profile!!)
    }
}

Testing the Auth Flow

@Test
fun `register returns access and refresh tokens`() = testApplication {
    application { module() }
    val client = jsonClient()

    val response = client.post("/api/auth/register") {
        contentType(ContentType.Application.Json)
        setBody(RegisterRequest("Alex", "alex@example.com", "password123"))
    }
    assertEquals(HttpStatusCode.Created, response.status)
    val token = response.body<TokenResponse>()
    assertNotNull(token.accessToken)
    assertNotNull(token.refreshToken)
}

@Test
fun `used refresh token cannot be reused`() = testApplication {
    application { module() }
    val client = jsonClient()

    val tokens = client.post("/api/auth/register") {
        contentType(ContentType.Application.Json)
        setBody(RegisterRequest("Casey", "casey@example.com", "password123"))
    }.body<TokenResponse>()

    // Use refresh token once
    client.post("/api/auth/refresh") {
        contentType(ContentType.Application.Json)
        setBody(RefreshTokenRequest(tokens.refreshToken))
    }

    // Try to reuse - should fail
    val response = client.post("/api/auth/refresh") {
        contentType(ContentType.Application.Json)
        setBody(RefreshTokenRequest(tokens.refreshToken))
    }
    assertEquals(HttpStatusCode.Unauthorized, response.status)
}

@Test
fun `logout revokes refresh tokens`() = testApplication {
    application { module() }
    val client = jsonClient()

    val tokens = client.post("/api/auth/register") {
        contentType(ContentType.Application.Json)
        setBody(RegisterRequest("Morgan", "morgan@example.com", "password123"))
    }.body<TokenResponse>()

    // Logout
    client.post("/api/auth/logout") {
        bearerAuth(tokens.accessToken)
    }

    // Refresh token should not work
    val response = client.post("/api/auth/refresh") {
        contentType(ContentType.Application.Json)
        setBody(RefreshTokenRequest(tokens.refreshToken))
    }
    assertEquals(HttpStatusCode.Unauthorized, response.status)
}

Testing with curl

# Register
curl -X POST http://localhost:8080/api/auth/register \
  -H "Content-Type: application/json" \
  -d '{"name": "Alex", "email": "alex@example.com", "password": "password123"}'

# Login
curl -X POST http://localhost:8080/api/auth/login \
  -H "Content-Type: application/json" \
  -d '{"email": "alex@example.com", "password": "password123"}'

# Refresh token
curl -X POST http://localhost:8080/api/auth/refresh \
  -H "Content-Type: application/json" \
  -d '{"refreshToken": "YOUR_REFRESH_TOKEN"}'

# Get profile
curl http://localhost:8080/api/auth/me \
  -H "Authorization: Bearer YOUR_ACCESS_TOKEN"

# Logout
curl -X POST http://localhost:8080/api/auth/logout \
  -H "Authorization: Bearer YOUR_ACCESS_TOKEN"

Security Best Practices

1. Never Return Different Errors for “User Not Found” vs “Wrong Password”

Use the same generic message: “Invalid email or password”. Different messages let attackers discover which emails are registered.

2. Validate Password Strength

Weak passwords are the most common security problem. Require at least 8 characters with a mix of letters and digits.

3. Validate Email Format

Reject invalid email formats before hitting the database.

4. Use Token Rotation

Always revoke the old refresh token when issuing a new one. If a token is reused, it means it was stolen.

5. Rate Limit Auth Endpoints

In the next tutorial, we will add rate limiting to prevent brute-force attacks.

6. Store Secrets in Environment Variables

val secret = System.getenv("JWT_SECRET")
    ?: "fallback-for-development-only"

Common Mistakes

  1. Storing passwords in plain text — Always use bcrypt or Argon2.
  2. Long-lived access tokens without refresh — Use short-lived access tokens (1 hour) with refresh tokens.
  3. Not revoking refresh tokens on logout — Users expect logout to actually log them out.
  4. Different error messages for login failures — This leaks information about registered emails.

Source Code

You can find the source code for this tutorial on GitHub:

github.com/kemalcodes/ktor-tutorial — Branch: tutorial-12-auth-flow

What’s Next?

You have a complete registration and login flow with refresh tokens, validation, and logout. In the next tutorial, you will add OAuth 2.0 with Google Sign-In — so users can login with their Google account.