Your API has registration and login with email and password. But many users prefer to sign in with their Google account. It is faster and they do not need to remember another password.
In this tutorial, you will add Google Sign-In using OAuth 2.0. You will learn how the OAuth flow works, how to handle the callback, and how to link OAuth accounts with existing email accounts.
How OAuth 2.0 Works
OAuth 2.0 is a protocol that lets users sign in with a third-party provider (Google, GitHub, etc.) without sharing their password with your application.
1. User clicks "Sign in with Google"
2. Your server redirects to Google's login page
3. User logs in with Google
4. Google redirects back to your server with an authorization code
5. Your server exchanges the code for an access token
6. Your server uses the access token to fetch user info from Google
7. Your server creates or finds the user and returns a JWT token
The important thing: Your server never sees the user’s Google password. Google handles authentication. Your server only gets the user’s profile information.
Google Cloud Setup
Before writing code, you need to create OAuth credentials in Google Cloud Console:
- Go to console.cloud.google.com
- Create a new project or select an existing one
- Go to APIs & Services > Credentials
- Click Create Credentials > OAuth client ID
- Select Web application
- Add
http://localhost:8080/api/auth/google/callbackto Authorized redirect URIs - Copy the Client ID and Client Secret
Store these as environment variables:
export GOOGLE_CLIENT_ID="your-client-id.apps.googleusercontent.com"
export GOOGLE_CLIENT_SECRET="your-client-secret"
Dependencies
The Ktor OAuth plugin is included in ktor-server-auth. You also need the Ktor HTTP client to fetch user info from Google:
dependencies {
// Already have ktor-server-auth from JWT tutorial
// Ktor Client (for Google user info request)
implementation("io.ktor:ktor-client-core:$ktorVersion")
implementation("io.ktor:ktor-client-java:$ktorVersion")
implementation("io.ktor:ktor-client-content-negotiation:$ktorVersion")
}
Database Migration
Add columns to store the OAuth provider information:
-- V7__add_oauth_provider_to_users.sql
ALTER TABLE users ADD COLUMN oauth_provider VARCHAR(50) DEFAULT NULL;
ALTER TABLE users ADD COLUMN oauth_provider_id VARCHAR(255) DEFAULT NULL;
And update the Exposed table definition:
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")
val oauthProvider = varchar("oauth_provider", 50).nullable().default(null)
val oauthProviderId = varchar("oauth_provider_id", 255).nullable().default(null)
override val primaryKey = PrimaryKey(id)
}
Configure OAuth
Add the Google OAuth provider to your authentication configuration:
// OAuth configuration
object OAuthConfig {
val googleClientId = System.getenv("GOOGLE_CLIENT_ID") ?: "your-google-client-id"
val googleClientSecret = System.getenv("GOOGLE_CLIENT_SECRET") ?: "your-google-client-secret"
const val CALLBACK_URL = "http://localhost:8080/api/auth/google/callback"
}
fun Application.configureAuthentication() {
install(Authentication) {
// JWT (from previous tutorial)
jwt("auth-jwt") { /* ... */ }
// Google OAuth 2.0
oauth("google-oauth") {
urlProvider = { OAuthConfig.CALLBACK_URL }
providerLookup = {
OAuthServerSettings.OAuth2ServerSettings(
name = "google",
authorizeUrl = "https://accounts.google.com/o/oauth2/v2/auth",
accessTokenUrl = "https://oauth2.googleapis.com/token",
requestMethod = HttpMethod.Post,
clientId = OAuthConfig.googleClientId,
clientSecret = OAuthConfig.googleClientSecret,
defaultScopes = listOf(
"https://www.googleapis.com/auth/userinfo.profile",
"https://www.googleapis.com/auth/userinfo.email"
)
)
}
client = HttpClient(Java)
}
}
}
The defaultScopes tell Google what information you need. The profile scope gives you the user’s name and picture. The email scope gives you their email.
Google User Info Model
Create a model for the Google user info response:
@Serializable
data class GoogleUserInfo(
val id: String,
val email: String,
val name: String,
@SerialName("picture")
val pictureUrl: String = ""
)
Account Linking
When a user signs in with Google, three things can happen:
- New user: Create a new account from the Google profile
- Existing OAuth user: The user has signed in with Google before. Find them by provider ID.
- Existing email user: The user registered with email/password but the same email. Link the accounts.
suspend fun findOrCreateOAuthUser(
email: String,
name: String,
provider: String,
providerId: String
): ProfileResponse = dbQuery {
// Check if user exists with this OAuth provider
val existingOAuth = Users.selectAll()
.where { (Users.oauthProvider eq provider) and (Users.oauthProviderId eq providerId) }
.singleOrNull()
if (existingOAuth != null) {
return@dbQuery resultRowToProfile(existingOAuth)
}
// Check if user exists with this email (link accounts)
val existingEmail = Users.selectAll()
.where { Users.email eq email }
.singleOrNull()
if (existingEmail != null) {
// Link OAuth provider to existing account
Users.update({ Users.id eq existingEmail[Users.id] }) {
it[oauthProvider] = provider
it[oauthProviderId] = providerId
}
return@dbQuery resultRowToProfile(
Users.selectAll().where { Users.id eq existingEmail[Users.id] }.single()
)
}
// Create new user from OAuth
val result = Users.insert {
it[Users.name] = name
it[Users.email] = email
it[oauthProvider] = provider
it[oauthProviderId] = providerId
}
resultRowToProfile(result.resultedValues!!.first())
}
OAuth Routes
Create the OAuth routes:
fun Route.oauthRoutes(
userRepository: UserRepository,
refreshTokenRepository: RefreshTokenRepository,
httpClient: HttpClient
) {
// Start Google OAuth flow
authenticate("google-oauth") {
get("/auth/google") {
// Ktor handles redirect to Google automatically
}
// Google redirects back here
get("/auth/google/callback") {
val principal = call.principal<OAuthAccessTokenResponse.OAuth2>()
if (principal == null) {
call.respond(HttpStatusCode.Unauthorized,
ErrorResponse("OAuth authentication failed", 401))
return@get
}
// Fetch user info from Google
val userInfo = httpClient.get("https://www.googleapis.com/oauth2/v2/userinfo") {
bearerAuth(principal.accessToken)
}.body<GoogleUserInfo>()
// Find or create user
val user = userRepository.findOrCreateOAuthUser(
email = userInfo.email,
name = userInfo.name,
provider = "google",
providerId = userInfo.id
)
// Generate JWT tokens
val accessToken = JwtConfig.generateAccessToken(user.id, user.email, user.role)
val refreshToken = JwtConfig.generateRefreshToken()
refreshTokenRepository.create(user.id, refreshToken)
call.respond(TokenResponse(accessToken, refreshToken))
}
}
}
How to Test
- Start the server:
./gradlew run - Open
http://localhost:8080/api/auth/googlein your browser - Google’s login page opens
- Log in with your Google account
- Google redirects back to your server
- You receive a JSON response with access and refresh tokens
For mobile or SPA clients, the flow is slightly different. The client handles the Google login and sends the token to your server. But the server-side flow shown here is the standard approach for web applications.
Adding GitHub OAuth
Adding another OAuth provider follows the same pattern. Add a new OAuth configuration:
oauth("github-oauth") {
urlProvider = { "http://localhost:8080/api/auth/github/callback" }
providerLookup = {
OAuthServerSettings.OAuth2ServerSettings(
name = "github",
authorizeUrl = "https://github.com/login/oauth/authorize",
accessTokenUrl = "https://github.com/login/oauth/access_token",
requestMethod = HttpMethod.Post,
clientId = System.getenv("GITHUB_CLIENT_ID") ?: "",
clientSecret = System.getenv("GITHUB_CLIENT_SECRET") ?: "",
defaultScopes = listOf("user:email")
)
}
client = HttpClient(Java)
}
Then add the callback route with the same pattern: fetch user info, find or create user, return tokens.
Security Considerations
Validate the State Parameter
Ktor’s OAuth plugin handles the state parameter automatically. This prevents CSRF attacks where an attacker tricks a user into linking their account.
Use HTTPS in Production
OAuth tokens travel in the URL during the redirect. Without HTTPS, these tokens can be intercepted.
Store OAuth Tokens Securely
If you need to access Google APIs on behalf of the user (like reading their calendar), store Google’s access and refresh tokens in the database. Encrypt them at rest.
Handle Account Linking Carefully
When linking an OAuth account to an existing email account, consider requiring the user to verify their email first. Otherwise, an attacker who controls a Google account with someone else’s email could take over their account.
Common Mistakes
- Not handling account linking — Users expect to sign in with Google and see the same account as their email registration.
- Hardcoding OAuth secrets — Use environment variables. Never commit client secrets to Git.
- Not validating the state parameter — Ktor handles this, but if you build a custom flow, always validate it.
- Forgetting to request the email scope — Without the email scope, you cannot identify the user.
Source Code
You can find the source code for this tutorial on GitHub:
github.com/kemalcodes/ktor-tutorial — Branch: tutorial-13-oauth
What’s Next?
You have both password-based and Google OAuth authentication. In the next tutorial, you will add Rate Limiting, CORS, and Security Headers — essential security features for any production API.
Related Articles
- Ktor Tutorial #12: Registration and Login Flow — Complete auth flow
- Ktor Tutorial #11: JWT Authentication — JWT basics
- Ktor Tutorial #6: Exposed ORM — Database setup
- Kotlin Tutorial: Complete Series — Learn Kotlin from scratch