Login con Google en Android con Kotlin: Credential Manager, Compose y validación del token en servidor

Durante años, añadir «iniciar sesión con…» a una app Android significaba elegir entre integrar un SDK por proveedor (Facebook, Twitter, Google, GitHub) o delegar todo en un servicio intermedio que prometía unificarlos. CloudRail fue uno de esos intermediarios, con una API única para el login social, pero su empresa abandonó esa línea de producto y sus SDKs figuran hoy como descontinuados. Del lado de Google también cambió el panorama: GoogleSignInClient y One Tap forman parte de las APIs heredadas, y la documentación oficial presenta Credential Manager como su reemplazo.

Este artículo recorre la implementación actual del inicio de sesión con Google en Android con Kotlin: dependencias, los dos flujos de interfaz (hoja inferior y botón), una pantalla en Compose, el manejo de errores y, sobre todo, lo que casi nunca se explica bien, que es validar el ID token en tu servidor. Al final se ve cómo encajan Firebase Auth y otros proveedores como GitHub.

Qué es Credential Manager y por qué importa

Credential Manager es la API de Jetpack recomendada para el intercambio de credenciales en Android. Con una sola llamada unifica passkeys, contraseñas guardadas, «Sign in with Google» y otros inicios de sesión federados, y los presenta en una interfaz del sistema coherente. Funciona en móviles, Wear OS y Android XR, y se integra con gestores de contraseñas como Google Password Manager.

Para el login con Google aporta tres cosas concretas: una hoja inferior que aparece sobre tu app sin sacar al usuario de contexto, inicio de sesión automático para quien ya autorizó la aplicación, y un token (GoogleIdTokenCredential) que sirve igual en Android, iOS y web. Si ya has seguido la evolución de la plataforma con Jetpack Compose o con Kotlin Multiplatform, esto encaja en el mismo estilo: una API en corrutinas y casi nada de código de pegamento.

Preparación en Google Cloud

Antes de escribir una línea de Kotlin hay que configurar la Google Auth Platform en un proyecto de Google Cloud. Hacen falta dos identificadores de cliente OAuth:

  • Un cliente de tipo «Web application». Su ID es el que pasas a setServerClientId() y el que tu servidor usará como audience al validar el token. Confundirlo con el ID de Android es el error de configuración más habitual.
  • Un cliente de tipo «Android», con el nombre de paquete y la huella SHA-1 del certificado con el que firmas la app. Hay que registrar la huella de depuración y la de release (o la de firma de Play App Signing) por separado.

Además, la verificación de marca (brand verification) es necesaria para que el nombre de tu app aparezca en la pantalla de consentimiento.

Dependencias

Se necesitan tres artefactos: la librería credentials, su puente con Google Play services (obligatorio para el login con Google y para dispositivos con Android 13 o anterior) y la librería googleid. A fecha de noviembre de 2026, la versión estable de androidx.credentials es la 1.6.0 (abril de 2026); existe además la 1.7.0-alpha03, que es la que muestra la guía oficial. La última versión publicada de googleid es la 1.2.1 (septiembre de 2026).

// gradle/libs.versions.toml
[versions]
credentials = "1.6.0"
googleid = "1.2.1"

[libraries]
androidx-credentials = { module = "androidx.credentials:credentials", version.ref = "credentials" }
androidx-credentials-play = { module = "androidx.credentials:credentials-play-services-auth", version.ref = "credentials" }
googleid = { module = "com.google.android.libraries.identity.googleid:googleid", version.ref = "googleid" }

// app/build.gradle.kts
dependencies {
    implementation(libs.androidx.credentials)
    implementation(libs.androidx.credentials.play)
    implementation(libs.googleid)
}

Si olvidas credentials-play-services-auth, la llamada falla con GetCredentialProviderConfigurationException («no provider dependencies found»). Desde la 1.6.0-alpha05 la librería sube su minSdk por defecto de 21 a 23.

Los dos flujos: hoja inferior y botón

Google recomienda implementar ambos, porque cubren casos distintos. La hoja inferior es descartable: si el usuario la cierra, necesita un botón persistente para reintentar. Tampoco aparece si no hay cuentas de Google en el dispositivo o si alguna requiere reautenticación, y el botón sí permite añadir una cuenta nueva.

Cada flujo usa una opción distinta:

  • GetGoogleIdOption para la hoja inferior. Con setFilterByAuthorizedAccounts(true) solo ofrece cuentas que ya usaron tu app; con false muestra todas las del dispositivo. La estrategia recomendada es probar primero con las autorizadas (con setAutoSelectEnabled(true)) y, si llega una NoCredentialException, repetir con el filtro desactivado.
  • GetSignInWithGoogleOption para el botón «Continuar con Google». Solo necesita el ID de cliente web.

El inicio de sesión automático solo se activa si hay una única cuenta autorizada en el dispositivo, si el usuario no cerró sesión explícitamente en la sesión anterior y si no lo desactivó en los ajustes de su cuenta de Google.

El nonce: protección contra repetición

Ambas opciones admiten setNonce(). Es un valor aleatorio que Google incluye dentro del ID token firmado; si tu servidor lo compara con el que esperaba, un token robado y reenviado después no sirve. El ejemplo de la documentación genera el nonce en el cliente:

fun generateSecureRandomNonce(byteLength: Int = 32): String {
    val randomBytes = ByteArray(byteLength)
    SecureRandom().nextBytes(randomBytes)
    return Base64.encodeToString(
        randomBytes,
        Base64.NO_WRAP or Base64.URL_SAFE or Base64.NO_PADDING
    )
}

Hay un matiz importante: si el nonce nace en el cliente y el servidor nunca lo vio, no tiene nada con lo que compararlo y la protección queda en nada. Para que sirva de verdad, lo razonable es que el servidor genere el nonce, lo asocie a la sesión o al intento de login, lo entregue a la app y lo invalide en cuanto reciba el token.

Un gestor de inicio de sesión en Kotlin

En lugar de repartir la lógica por las pantallas, conviene encapsularla en una clase. Este gestor intenta primero las cuentas autorizadas, cae a todas las cuentas si no hay ninguna y expone el botón como segundo método:

import android.content.Context
import androidx.credentials.CredentialManager
import androidx.credentials.CredentialOption
import androidx.credentials.CustomCredential
import androidx.credentials.GetCredentialRequest
import androidx.credentials.GetCredentialResponse
import androidx.credentials.exceptions.GetCredentialCancellationException
import androidx.credentials.exceptions.GetCredentialException
import androidx.credentials.exceptions.NoCredentialException
import com.google.android.libraries.identity.googleid.GetGoogleIdOption
import com.google.android.libraries.identity.googleid.GetSignInWithGoogleOption
import com.google.android.libraries.identity.googleid.GoogleIdTokenCredential
import com.google.android.libraries.identity.googleid.GoogleIdTokenParsingException

sealed interface SignInResult {
    data class Success(
        val idToken: String,
        val email: String,
        val displayName: String?
    ) : SignInResult
    data object Cancelled : SignInResult
    data object NoAccounts : SignInResult
    data class Failure(val cause: Throwable) : SignInResult
}

class GoogleSignInManager(
    context: Context,
    private val webClientId: String
) {
    private val credentialManager = CredentialManager.create(context)

    /** Hoja inferior: primero cuentas autorizadas, después todas. */
    suspend fun signIn(activityContext: Context, nonce: String): SignInResult {
        val authorized = GetGoogleIdOption.Builder()
            .setFilterByAuthorizedAccounts(true)
            .setServerClientId(webClientId)
            .setAutoSelectEnabled(true)
            .setNonce(nonce)
            .build()

        val first = request(activityContext, authorized)
        if (first !is SignInResult.NoAccounts) return first

        val anyAccount = GetGoogleIdOption.Builder()
            .setFilterByAuthorizedAccounts(false)
            .setServerClientId(webClientId)
            .setNonce(nonce)
            .build()
        return request(activityContext, anyAccount)
    }

    /** Botón «Continuar con Google». */
    suspend fun signInWithButton(activityContext: Context, nonce: String): SignInResult {
        val option = GetSignInWithGoogleOption.Builder(webClientId)
            .setNonce(nonce)
            .build()
        return request(activityContext, option)
    }

    private suspend fun request(context: Context, option: CredentialOption): SignInResult {
        val request = GetCredentialRequest.Builder()
            .addCredentialOption(option)
            .build()
        return try {
            toResult(credentialManager.getCredential(context = context, request = request))
        } catch (e: NoCredentialException) {
            SignInResult.NoAccounts
        } catch (e: GetCredentialCancellationException) {
            SignInResult.Cancelled
        } catch (e: GetCredentialException) {
            SignInResult.Failure(e)
        }
    }

    private fun toResult(response: GetCredentialResponse): SignInResult {
        val credential = response.credential
        if (credential !is CustomCredential ||
            credential.type != GoogleIdTokenCredential.TYPE_GOOGLE_ID_TOKEN_CREDENTIAL
        ) {
            return SignInResult.Failure(IllegalStateException("Tipo de credencial inesperado"))
        }
        return try {
            val google = GoogleIdTokenCredential.createFrom(credential.data)
            SignInResult.Success(google.idToken, google.id, google.displayName)
        } catch (e: GoogleIdTokenParsingException) {
            SignInResult.Failure(e)
        }
    }

    /** Llamar al cerrar sesión para que los proveedores olviden la sesión activa. */
    suspend fun signOut() {
        credentialManager.clearCredentialState(
            androidx.credentials.ClearCredentialStateRequest()
        )
    }
}

Dos detalles del código. El contexto que se pasa a getCredential debe ser el de una Activity en primer plano; con un contexto de aplicación, el comportamiento de la interfaz del sistema queda indefinido. Y como la llamada suspende mientras la hoja está abierta, una rotación de pantalla puede dejar la actividad antigua retenida. La guía oficial propone envolver el contexto en un MutableContextWrapper, y la 1.7.0-alpha03 añade el intercambio automático del contexto para evitar esa fuga.

La pantalla en Compose

La hoja inferior se lanza al entrar en la pantalla y el botón queda como alternativa. La llamada a AuthApi representa tu backend (la definición es un ejemplo mínimo):

interface AuthApi {
    suspend fun requestNonce(): String
    suspend fun loginWithGoogle(idToken: String): Boolean
}

@Composable
fun LoginScreen(
    manager: GoogleSignInManager,
    api: AuthApi,
    onLoggedIn: () -> Unit
) {
    val context = LocalContext.current // debe ser el de la Activity
    val scope = rememberCoroutineScope()
    var message by remember { mutableStateOf<String?>(null) }

    fun start(useButton: Boolean) {
        scope.launch {
            val nonce = api.requestNonce()
            val result = if (useButton) {
                manager.signInWithButton(context, nonce)
            } else {
                manager.signIn(context, nonce)
            }
            when (result) {
                is SignInResult.Success -> {
                    if (api.loginWithGoogle(result.idToken)) onLoggedIn()
                    else message = "El servidor rechazó el inicio de sesión."
                }
                SignInResult.Cancelled -> message = null // no reintentar solo
                SignInResult.NoAccounts ->
                    message = "No hay cuentas de Google disponibles en este dispositivo."
                is SignInResult.Failure ->
                    message = "No se pudo iniciar sesión. Inténtalo de nuevo."
            }
        }
    }

    LaunchedEffect(Unit) { start(useButton = false) }

    Column(
        modifier = Modifier.fillMaxSize().padding(24.dp),
        verticalArrangement = Arrangement.Center,
        horizontalAlignment = Alignment.CenterHorizontally
    ) {
        Button(onClick = { start(useButton = true) }) {
            Text("Continuar con Google")
        }
        message?.let { Text(it, modifier = Modifier.padding(top = 16.dp)) }
    }
}

El botón de producción debe seguir las guías de marca de Sign in with Google, que ofrecen recursos aprobados; un Button de Material con texto sirve para pruebas. Ni el estado ni la lógica de red deberían vivir en el composable en una app real: lo normal es mover start() a un ViewModel.

Errores que conviene tratar de forma distinta

La guía de resolución de problemas de Credential Manager documenta las excepciones más frecuentes:

  • NoCredentialException: el filtro de cuentas autorizadas está activo sin que haya ninguna, no hay cuentas en el dispositivo o necesitan reautenticarse. También ocurre si el usuario desactivó «Sign-in prompts» en los ajustes de su cuenta de Google, lo que suprime la hoja inferior para todas las cuentas, aunque no afecta al flujo del botón. Aquí toca mostrar el botón.
  • GetCredentialCancellationException: el usuario cerró el flujo, o la autorización no se completó por una restricción técnica. No debe reintentarse de forma automática. Una tasa alta de cancelaciones en tus métricas suele indicar una configuración errónea.
  • GetCredentialProviderConfigurationException: falta la dependencia credentials-play-services-auth.
  • GetCredentialUnsupportedException: el dispositivo no soporta Credential Manager; la guía pide tener al menos la versión 1.2.1 de la librería.
  • TransactionTooLargeException: error conocido en Android 14 y superior cuando hay varias cuentas de Google en el dispositivo; afecta a GetGoogleIdOption y no a GetSignInWithGoogleOption, y está corregido en Google Play services 24.40 y posteriores.

Validar el token en el servidor: no te saltes este paso

Que la app reciba un ID token no demuestra nada: cualquiera puede enviar un JWT inventado a tu endpoint. El servidor debe comprobar la firma contra las claves públicas de Google (que rotan, por lo que se cachean según la cabecera Cache-Control) y además estos campos:

  • aud igual a uno de los IDs de cliente de tu app, es decir, el ID de cliente web. Evita que un token emitido para otra aplicación sirva contra tu API.
  • iss igual a accounts.google.com o https://accounts.google.com.
  • exp sin caducar.
  • nonce igual al que emitió tu servidor para ese intento (si lo usas).
  • hd, solo si quieres restringir el acceso a una organización de Google Workspace.

Para el identificador del usuario, Google indica usar el campo sub, único entre todas las cuentas y nunca reutilizado; el correo puede cambiar, así que no sirve como clave. email_verified ayuda a decidir si Google es autoridad sobre esa dirección, algo que garantiza en cuentas @gmail.com y en cuentas de Workspace con hd presente.

En Java o Kotlin, la biblioteca google-api-client ofrece GoogleIdTokenVerifier, que comprueba firma, aud, iss y exp y devuelve null si el token no es válido. Equivalentes oficiales: google-auth-library para Node.js (verifyIdToken()), google/apiclient para PHP y google-auth para Python.

import com.google.api.client.googleapis.auth.oauth2.GoogleIdToken
import com.google.api.client.googleapis.auth.oauth2.GoogleIdTokenVerifier
import com.google.api.client.http.javanet.NetHttpTransport
import com.google.api.client.json.gson.GsonFactory

class GoogleTokenValidator(webClientId: String) {

    private val verifier = GoogleIdTokenVerifier.Builder(
        NetHttpTransport(),
        GsonFactory.getDefaultInstance()
    )
        .setAudience(listOf(webClientId))
        .build()

    /** Devuelve el payload si el token es válido y el nonce coincide; null en otro caso. */
    fun validate(idTokenString: String, expectedNonce: String): GoogleIdToken.Payload? {
        val idToken = verifier.verify(idTokenString) ?: return null
        val payload = idToken.payload
        if (payload.nonce != expectedNonce) return null
        return payload
    }
}

// Uso en el endpoint de login:
// val payload = validator.validate(token, nonceGuardadoParaEstaSesion) ?: return 401
// val userId = payload.subject          // clave estable del usuario
// val email = payload.email             // solo informativo
// val verificado = payload.emailVerified

Tras validar, tu backend crea su propia sesión (un token de corta duración, por ejemplo) y la app deja de depender del ID token de Google. Ese token no está pensado como credencial de sesión duradera.

Cerrar sesión

Al cerrar sesión hay que llamar a clearCredentialState(), como hace signOut() en el gestor de arriba. Así los proveedores de credenciales descartan la sesión activa y el usuario recibe todas las opciones de inicio la próxima vez. Esto no revoca los permisos que el usuario concedió a tu app; eso se gestiona desde su cuenta de Google.

Si usas Firebase Authentication

Con Firebase el flujo del cliente es el mismo. Lo único que cambia es que, en vez de enviar el token a tu propio servidor, lo canjeas por una credencial de Firebase. En el proyecto se añade la BoM de Firebase junto con firebase-auth, y se mantienen las dependencias de Credential Manager y googleid:

private fun firebaseAuthWithGoogle(idToken: String) {
    val credential = GoogleAuthProvider.getCredential(idToken, null)
    auth.signInWithCredential(credential)
        .addOnCompleteListener(this) { task ->
            if (task.isSuccessful) {
                updateUI(auth.currentUser)
            } else {
                updateUI(null)
            }
        }
}

GitHub y otros proveedores

Credential Manager cubre Google, passkeys y contraseñas, pero no GitHub ni la mayoría de proveedores OAuth. Hay dos caminos habituales. El primero es Firebase Authentication, que abre el flujo del proveedor en una Custom Tab y devuelve el usuario ya autenticado. Con GitHub se resume así:

val provider = OAuthProvider.newBuilder("github.com")
provider.scopes = listOf("user:email")

val pending = firebaseAuth.pendingAuthResult
if (pending != null) {
    pending
        .addOnSuccessListener { result -> /* sesión iniciada */ }
        .addOnFailureListener { e -> /* gestionar error */ }
} else {
    firebaseAuth
        .startActivityForSignInWithProvider(activity, provider.build())
        .addOnSuccessListener { result -> /* sesión iniciada */ }
        .addOnFailureListener { e -> /* gestionar error */ }
}

Firebase avisa de que no se debe referenciar la Activity dentro de los listeners, porque el flujo la desvincula al abrir la Custom Tab. El segundo camino, si no quieres depender de Firebase, es AppAuth para Android, la biblioteca de referencia para implementar OAuth 2.0 y OpenID Connect con PKCE contra cualquier proveedor compatible, con tu propio backend como intermediario para canjear el código.

Lista de comprobación

  • Cliente OAuth web (para setServerClientId) y cliente Android con paquete y SHA-1 correctos, también para la firma de release.
  • Dependencias credentials, credentials-play-services-auth y googleid declaradas.
  • Hoja inferior primero con cuentas autorizadas, después con todas, y botón persistente como respaldo.
  • Contexto de Activity en la llamada a getCredential.
  • Nonce generado por el servidor y comprobado al validar.
  • Validación de firma, aud, iss y exp en el backend, y sub como identificador de usuario.
  • clearCredentialState() al cerrar sesión.

Fuentes consultadas: documentación de Android Developers sobre Credential Manager y Sign in with Google, notas de versión de androidx.credentials y de la librería googleid, guía de verificación del ID token de Google Identity y documentación de Firebase Authentication para Android.

Imagen: Pexels / Negative Space

COMPARTE ESTE ARTÍCULO

COMPARTIR EN FACEBOOK
COMPARTIR EN TWITTER
COMPARTIR EN LINKEDIN
COMPARTIR EN WHATSAPP