Skip to main content

Firebase Auth Deep Dive

Firebase Auth is the most common auth stack on Android because it handles the hard parts — token refresh, phone verification, social providers, multi-factor, revocation — with an API that fits the platform. This chapter covers every provider, session management, and the security posture you need for a production app.

The shape of auth

Firebase Auth flow
CLIENTMobile AppAUTHORIZATIONAuth ServerRESOURCEAPI Server1. POST /login (email, password)2. access_token + refresh_token3. GET /data (Authorization: Bearer <access>)4. 200 OK (data payload)5. 401 Unauthorized (token expired)6. POST /refresh (refresh_token)7. new access_token
Client signs in → gets ID token → backend verifies → session established.
┌───────────────────────────────────────────────────────────────────┐
│ Client (Android) │
│ 1. Sign in via provider (email / Google / phone / passkey) │
│ 2. Receive FirebaseUser + ID token │
│ 3. Send ID token to your backend │
│ │
│ Backend │
│ 4. Verify ID token via Firebase Admin SDK │
│ 5. Mint your own session cookie / JWT (optional) │
│ │
│ Firestore / Cloud Storage / Functions │
│ Auto-applies Security Rules using request.auth │
└───────────────────────────────────────────────────────────────────┘

Setup

// libs.versions.toml
firebase-bom = "33.7.0"
credentials = "1.5.0-rc01"
google-id = "1.1.1"

firebase-bom = { module = "com.google.firebase:firebase-bom", version.ref = "firebase-bom" }
firebase-auth = { module = "com.google.firebase:firebase-auth-ktx" }
firebase-auth-credentials = { module = "com.google.firebase:firebase-auth" }
play-services-auth = { module = "com.google.android.gms:play-services-auth", version = "21.3.0" }
credentials = { module = "androidx.credentials:credentials", version.ref = "credentials" }
credentials-play = { module = "androidx.credentials:credentials-play-services-auth", version.ref = "credentials" }
google-id = { module = "com.google.android.libraries.identity.googleid:googleid", version.ref = "google-id" }
// build.gradle.kts (app)
plugins {
id("com.google.gms.google-services")
id("com.google.firebase.crashlytics")
}

dependencies {
implementation(platform(libs.firebase.bom))
implementation(libs.firebase.auth)
implementation(libs.credentials)
implementation(libs.credentials.play)
implementation(libs.google.id)
}
// Initialize via App Startup for performance
@Singleton
class FirebaseBootstrap @Inject constructor() {
val auth: FirebaseAuth = Firebase.auth.apply {
useAppLanguage() // localize error messages
}
}

Email / password

class EmailAuthProvider @Inject constructor(
private val auth: FirebaseAuth
) {
suspend fun signUp(email: String, password: String): Outcome<AuthUser, AuthError> = try {
val result = auth.createUserWithEmailAndPassword(email, password).await()
result.user?.sendEmailVerification()?.await()
Outcome.Ok(result.user!!.toDomain())
} catch (e: FirebaseAuthWeakPasswordException) {
Outcome.Err(AuthError.WeakPassword(e.reason ?: "Password too weak"))
} catch (e: FirebaseAuthInvalidCredentialsException) {
Outcome.Err(AuthError.InvalidCredentials)
} catch (e: FirebaseAuthUserCollisionException) {
Outcome.Err(AuthError.EmailAlreadyInUse)
} catch (e: Exception) {
Outcome.Err(AuthError.Unknown(e))
}

suspend fun signIn(email: String, password: String): Outcome<AuthUser, AuthError> = try {
val result = auth.signInWithEmailAndPassword(email, password).await()
Outcome.Ok(result.user!!.toDomain())
} catch (e: FirebaseAuthInvalidUserException) {
Outcome.Err(AuthError.UserNotFound)
} catch (e: FirebaseAuthInvalidCredentialsException) {
Outcome.Err(AuthError.WrongPassword)
} catch (e: FirebaseAuthMultiFactorException) {
Outcome.Err(AuthError.MfaRequired(e.resolver))
} catch (e: Exception) {
Outcome.Err(AuthError.Unknown(e))
}

suspend fun sendPasswordReset(email: String): Outcome<Unit, AuthError> = try {
auth.sendPasswordResetEmail(email).await()
Outcome.Ok(Unit)
} catch (e: Exception) {
Outcome.Err(AuthError.Unknown(e))
}
}

Password policies

Configure strength requirements in the Firebase Console (Authentication → Settings → Password policy). Client-side, pre-validate before calling:

fun validatePassword(pw: String): PasswordValidation {
val issues = buildList {
if (pw.length < 8) add("At least 8 characters")
if (!pw.any(Char::isDigit)) add("At least one digit")
if (!pw.any(Char::isUpperCase)) add("At least one uppercase")
if (!pw.any { !it.isLetterOrDigit() }) add("At least one symbol")
}
return if (issues.isEmpty()) PasswordValidation.Strong else PasswordValidation.Weak(issues)
}

Email enumeration protection

Enable email enumeration protection in the Firebase Console so signInWithEmailAndPassword returns INVALID_LOGIN_CREDENTIALS whether the email exists or is wrong — preventing attackers from probing which emails have accounts.


Google sign-in (modern Credential Manager API)

GoogleSignIn via Play Services is deprecated. Use the Credential Manager API:

class GoogleAuthProvider @Inject constructor(
@ApplicationContext private val context: Context,
private val auth: FirebaseAuth
) {
private val credentialManager = CredentialManager.create(context)

suspend fun signIn(activity: Activity): Outcome<AuthUser, AuthError> {
val googleIdOption = GetGoogleIdOption.Builder()
.setFilterByAuthorizedAccounts(false)
.setServerClientId(BuildConfig.WEB_CLIENT_ID)
.setAutoSelectEnabled(true)
.setNonce(generateNonce())
.build()

val request = GetCredentialRequest.Builder()
.addCredentialOption(googleIdOption)
.build()

return try {
val result = credentialManager.getCredential(activity, request)
val credential = result.credential as? CustomCredential
?: return Outcome.Err(AuthError.Unknown(IllegalStateException("Unexpected credential")))

if (credential.type != GoogleIdTokenCredential.TYPE_GOOGLE_ID_TOKEN_CREDENTIAL) {
return Outcome.Err(AuthError.Unknown(IllegalStateException("Not a Google credential")))
}

val googleCredential = GoogleIdTokenCredential.createFrom(credential.data)
val firebaseCredential = GoogleAuthProvider.getCredential(googleCredential.idToken, null)
val authResult = auth.signInWithCredential(firebaseCredential).await()
Outcome.Ok(authResult.user!!.toDomain())
} catch (e: GetCredentialCancellationException) {
Outcome.Err(AuthError.Cancelled)
} catch (e: NoCredentialException) {
Outcome.Err(AuthError.NoGoogleAccount)
} catch (e: Exception) {
Outcome.Err(AuthError.Unknown(e))
}
}

private fun generateNonce(): String = ByteArray(32).apply { SecureRandom().nextBytes(this) }.toHex()
}

Passkeys (WebAuthn) — the passwordless future

Android 14+ supports passkeys natively via Credential Manager:

suspend fun signInWithPasskey(activity: Activity): Outcome<AuthUser, AuthError> {
// Your backend generates a WebAuthn challenge
val challenge = api.createAuthChallenge()

val passkeyOption = GetPublicKeyCredentialOption(
requestJson = challenge.toJson()
)

val request = GetCredentialRequest.Builder()
.addCredentialOption(passkeyOption)
.build()

return try {
val result = credentialManager.getCredential(activity, request)
val credential = result.credential as PublicKeyCredential
val authResponse = api.verifyPasskey(credential.authenticationResponseJson)

// Sign in via custom token
val authResult = auth.signInWithCustomToken(authResponse.firebaseToken).await()
Outcome.Ok(authResult.user!!.toDomain())
} catch (e: Exception) {
Outcome.Err(AuthError.Unknown(e))
}
}

Firebase doesn't natively issue passkeys but integrates via custom tokens — your backend does the WebAuthn dance, mints a custom token, the client signs in with it.


Phone authentication

class PhoneAuthProvider @Inject constructor(
private val auth: FirebaseAuth
) {
private var verificationId: String? = null
private var resendToken: PhoneAuthProvider.ForceResendingToken? = null

suspend fun sendCode(
activity: Activity,
phoneNumber: String
): Outcome<Unit, AuthError> = suspendCancellableCoroutine { cont ->
val callback = object : PhoneAuthProvider.OnVerificationStateChangedCallbacks() {
override fun onVerificationCompleted(credential: PhoneAuthCredential) {
// Auto-verified — sign in immediately
cont.resume(Outcome.Ok(Unit)) { }
signInWithCredentialBlocking(credential)
}
override fun onVerificationFailed(e: FirebaseException) {
cont.resume(Outcome.Err(AuthError.Unknown(e))) { }
}
override fun onCodeSent(verId: String, token: PhoneAuthProvider.ForceResendingToken) {
verificationId = verId
resendToken = token
cont.resume(Outcome.Ok(Unit)) { }
}
}

val options = PhoneAuthOptions.newBuilder(auth)
.setPhoneNumber(phoneNumber)
.setTimeout(60, TimeUnit.SECONDS)
.setActivity(activity)
.setCallbacks(callback)
.build()

PhoneAuthProvider.verifyPhoneNumber(options)
}

suspend fun verifyCode(smsCode: String): Outcome<AuthUser, AuthError> = try {
val verId = verificationId ?: return Outcome.Err(AuthError.NoCodeSent)
val credential = PhoneAuthProvider.getCredential(verId, smsCode)
val result = auth.signInWithCredential(credential).await()
Outcome.Ok(result.user!!.toDomain())
} catch (e: FirebaseAuthInvalidCredentialsException) {
Outcome.Err(AuthError.WrongCode)
} catch (e: Exception) {
Outcome.Err(AuthError.Unknown(e))
}
}

SMS retrieval (auto-fill OTP)

// Include in onStart/onStop
val smsRetriever = SmsRetriever.getClient(context)
smsRetriever.startSmsRetriever()

// BroadcastReceiver catches the incoming SMS
class OtpReceiver : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
if (intent?.action != SmsRetriever.SMS_RETRIEVED_ACTION) return
val extras = intent.extras ?: return
val status = extras[SmsRetriever.EXTRA_STATUS] as? Status ?: return
if (status.statusCode != CommonStatusCodes.SUCCESS) return

val message = extras[SmsRetriever.EXTRA_SMS_MESSAGE] as? String ?: return
val otp = Regex("\\b\\d{6}\\b").find(message)?.value
// Fill the OTP field automatically
}
}

Your backend must include an 11-character hash (derived from the app's signing certificate) in the SMS for Google Play Services to route it to your app.


Anonymous authentication

suspend fun signInAnonymously(): Outcome<AuthUser, AuthError> = try {
val result = auth.signInAnonymously().await()
Outcome.Ok(result.user!!.toDomain())
} catch (e: Exception) {
Outcome.Err(AuthError.Unknown(e))
}

Use anonymous auth to give new users a ready-to-use account. When they later sign in with a permanent provider, link instead of creating a new account so their data carries over.


Account linking

suspend fun linkWithEmail(email: String, password: String): Outcome<AuthUser, AuthError> = try {
val user = auth.currentUser ?: return Outcome.Err(AuthError.NotSignedIn)
val credential = EmailAuthProvider.getCredential(email, password)
val result = user.linkWithCredential(credential).await()
Outcome.Ok(result.user!!.toDomain())
} catch (e: FirebaseAuthUserCollisionException) {
Outcome.Err(AuthError.EmailAlreadyInUse)
} catch (e: Exception) {
Outcome.Err(AuthError.Unknown(e))
}

This preserves the same UID — all the user's data, Firestore docs, custom claims stay attached. The alternative (sign in → separate account → manual data migration) is a nightmare.


Multi-Factor Authentication (MFA)

Enable SMS-based MFA in the Firebase Console, then on the client:

// Enroll
suspend fun enrollMfa(phoneNumber: String): Outcome<Unit, AuthError> {
val user = auth.currentUser ?: return Outcome.Err(AuthError.NotSignedIn)
val session = user.multiFactor.session.await()

val options = PhoneAuthOptions.newBuilder()
.setPhoneNumber(phoneNumber)
.setTimeout(60, TimeUnit.SECONDS)
.setActivity(activity)
.setMultiFactorSession(session)
.setCallbacks(/* callbacks to receive verificationId */)
.build()

PhoneAuthProvider.verifyPhoneNumber(options)
// After SMS verified, call:
val credential = PhoneAuthProvider.getCredential(verId, smsCode)
val assertion = PhoneMultiFactorGenerator.getAssertion(credential)
user.multiFactor.enroll(assertion, "Personal phone").await()
return Outcome.Ok(Unit)
}

// Sign-in with MFA resolver
suspend fun completeMfaSignIn(
resolver: MultiFactorResolver,
smsCode: String,
verificationId: String
): Outcome<AuthUser, AuthError> = try {
val credential = PhoneAuthProvider.getCredential(verificationId, smsCode)
val assertion = PhoneMultiFactorGenerator.getAssertion(credential)
val result = resolver.resolveSignIn(assertion).await()
Outcome.Ok(result.user!!.toDomain())
} catch (e: Exception) {
Outcome.Err(AuthError.WrongCode)
}

Firebase also supports TOTP (Google Authenticator-style) for users who don't want SMS.


Session management

Observing auth state

@Singleton
class AuthSession @Inject constructor(private val auth: FirebaseAuth) {

val userFlow: StateFlow<AuthUser?> = callbackFlow {
val listener = FirebaseAuth.AuthStateListener { a ->
trySend(a.currentUser?.toDomain())
}
auth.addAuthStateListener(listener)
awaitClose { auth.removeAuthStateListener(listener) }
}.stateIn(
scope = ProcessLifecycleOwner.get().lifecycleScope,
started = SharingStarted.Eagerly,
initialValue = auth.currentUser?.toDomain()
)

suspend fun signOut() = withContext(Dispatchers.IO) {
auth.signOut()
// Also revoke Google credential if signed in via Google
CredentialManager.create(context).clearCredentialState(ClearCredentialStateRequest())
}
}

Getting a fresh ID token

suspend fun backendCall(): Response {
val user = auth.currentUser ?: throw NotSignedInException()
val idToken = user.getIdToken(false).await().token!! // cached if < 55 min old

return api.fetchProtected(authHeader = "Bearer $idToken")
}

suspend fun forceRefreshIdToken(): String {
val user = auth.currentUser ?: throw NotSignedInException()
return user.getIdToken(true).await().token!! // force refresh
}

Tokens expire after 1 hour. getIdToken(false) returns the cached token if still valid. Force refresh after sensitive actions (password change, privilege elevation).

Auto-attaching the token via OkHttp

class FirebaseAuthInterceptor @Inject constructor(
private val auth: FirebaseAuth
) : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val user = auth.currentUser ?: return chain.proceed(chain.request())

val token = runBlocking { user.getIdToken(false).await().token }
?: return chain.proceed(chain.request())

val authed = chain.request().newBuilder()
.header("Authorization", "Bearer $token")
.build()

val response = chain.proceed(authed)

// Token expired? Force refresh and retry once.
if (response.code == 401) {
response.close()
val fresh = runBlocking { user.getIdToken(true).await().token }
if (fresh != null) {
val retry = chain.request().newBuilder()
.header("Authorization", "Bearer $fresh")
.build()
return chain.proceed(retry)
}
}
return response
}
}

Custom claims — roles and permissions

Firebase stores arbitrary key-value metadata on each user. Set them from a privileged backend:

// Cloud Function
await admin.auth().setCustomUserClaims(uid, {
role: 'admin',
tier: 'premium',
orgId: 'org_abc'
});
// Read from client
suspend fun getUserClaims(): Map<String, Any> {
val user = auth.currentUser ?: return emptyMap()
val tokenResult = user.getIdToken(false).await()
return tokenResult.claims
}

val isAdmin = getUserClaims()["role"] == "admin"

Never trust client-side claim values for security — always re-verify on the backend. Client-side claim checks are for UX (hide admin menu), not authorization.


Security Rules — the enforcement layer

Client sign-in is step 1. Server enforcement is step 2. Example Firestore rule using custom claims:

rules_version = '2';
service cloud.firestore {
match /databases/{db}/documents {
match /admin/{doc} {
allow read, write: if request.auth.token.role == 'admin';
}
match /users/{userId}/{restOfPath=**} {
allow read, write: if request.auth.uid == userId;
}
}
}

See the Firestore deep dive for the full rules language.


Delete account — comply with user rights

suspend fun deleteAccount(password: String): Outcome<Unit, AuthError> {
val user = auth.currentUser ?: return Outcome.Err(AuthError.NotSignedIn)
val email = user.email ?: return Outcome.Err(AuthError.InvalidState)

// Re-authenticate (Firebase requires recent auth for destructive ops)
val credential = EmailAuthProvider.getCredential(email, password)
try {
user.reauthenticate(credential).await()
} catch (e: Exception) {
return Outcome.Err(AuthError.WrongPassword)
}

// Delete Firestore data first — Cloud Function triggered on delete
// is best, but client-driven cleanup is needed as a fallback
firestore.collection("users").document(user.uid).delete().await()

return try {
user.delete().await()
Outcome.Ok(Unit)
} catch (e: Exception) {
Outcome.Err(AuthError.Unknown(e))
}
}

GDPR Article 17 ("right to erasure") and Google Play's Data Safety declaration require an in-app delete-account flow for any app that lets users create accounts.


Testing auth code

Use the Firebase Local Emulator Suite:

// build.gradle.kts (debug only)
debugImplementation("com.google.firebase:firebase-auth:25.3.0")

// App initialization
if (BuildConfig.DEBUG) {
Firebase.auth.useEmulator("10.0.2.2", 9099)
Firebase.firestore.useEmulator("10.0.2.2", 8080)
}
# Start the emulators
firebase emulators:start --only auth,firestore

Your integration tests can create users, set custom claims, and verify Firestore rules — all without touching production.


Common anti-patterns

Anti-patterns

Auth mistakes

  • Trusting client-side role checks for authorization
  • Caching ID tokens beyond their 1-hour lifetime
  • Using deprecated GoogleSignInClient API
  • No email enumeration protection
  • Creating a new account instead of linking anonymous
  • No re-authentication before destructive ops
Best practices

Production auth

  • Always verify tokens server-side; claims are UX only
  • getIdToken(false) for cached; getIdToken(true) to refresh
  • Credential Manager API for Google sign-in
  • Enable email enumeration protection in Console
  • linkWithCredential to upgrade anonymous users
  • reauthenticate() before delete-account / email change

Key takeaways

Practice exercises

  1. 01

    Credential Manager sign-in

    Replace any legacy GoogleSignInClient code with Credential Manager + Google ID credential option.

  2. 02

    Auth interceptor

    Write a FirebaseAuthInterceptor that attaches the ID token and force-refreshes on 401. Verify with a protected endpoint.

  3. 03

    Phone auth with SMS retrieval

    Implement phone OTP using PhoneAuthProvider + SmsRetriever for auto-fill. Include the 11-char app hash in your SMS template.

  4. 04

    Custom claims UX

    Set a `role: admin` claim via a Cloud Function. Read it from the client via getIdToken. Show an admin menu conditionally.

  5. 05

    Delete account

    Build a two-step delete-account flow: re-authenticate, then delete Firestore user doc + Auth account. Add a confirmation dialog.

Next

Continue to Firestore for real-time queries, transactions, offline persistence, and security rules.