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:

  1. Go to console.cloud.google.com
  2. Create a new project or select an existing one
  3. Go to APIs & Services > Credentials
  4. Click Create Credentials > OAuth client ID
  5. Select Web application
  6. Add http://localhost:8080/api/auth/google/callback to Authorized redirect URIs
  7. 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:

  1. New user: Create a new account from the Google profile
  2. Existing OAuth user: The user has signed in with Google before. Find them by provider ID.
  3. 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

  1. Start the server: ./gradlew run
  2. Open http://localhost:8080/api/auth/google in your browser
  3. Google’s login page opens
  4. Log in with your Google account
  5. Google redirects back to your server
  6. 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

  1. Not handling account linking — Users expect to sign in with Google and see the same account as their email registration.
  2. Hardcoding OAuth secrets — Use environment variables. Never commit client secrets to Git.
  3. Not validating the state parameter — Ktor handles this, but if you build a custom flow, always validate it.
  4. 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.