Part 2 — Building a Secure, Practical Authentication System for Android

K

Karishma Agrawal

Guest

Part 2 — Building a Secure, Practical Authentication System for Android​

1*ZTH0dsqXvShFpfdGGNPztQ.jpeg

Photo by Matthew Henry on Unsplash

Table of contents​

  1. Quick recap (20s)
  2. Real-world threat model (2 min)
  3. Design goals & trade-offs (2 min)
  4. Architecture overview (3 min)
  5. Secure token lifecycle (with code) (6 min)
  6. Biometric & key-protected flows (with code) (6 min)
  7. Device trust and Play Integrity (2 min)
  8. Rate limiting, lockouts & anomaly detection (2 min)

1 — Quick recap​


Part 1 covered the what and why: password-based auth, OTP, third-party providers (Firebase/Auth0), Play Integrity, and rate-limiting basics. Part 2 answers: how you put those pieces together into a secure, scalable Android flow that real users love and real ops teams can support.

Part 1, you can access from link below

Design User Authentication System in Android App

2 — Real-world threat model (and a few stories)​


Before you design anything, be explicit about what you’re protecting against. Real examples:

  • Credential stuffing: Credential stuffing is a type of cyberattack where hackers use stolen username-password combinations (usually obtained from previous data breaches) and try them across multiple websites or apps, hoping that users have reused the same credentials.
  • Stolen device: Imagine you lose your phone at a café. You’ve kept your banking app logged in because you hate typing your password every time. The thief now has your phone in hand, but they can’t unlock it using your biometric (fingerprint or face). Still, some apps — yours included — might allow access to sensitive data if they were left in a logged-in state.
  • SIM swap / OTP takeover: Ravi’s bank app uses OTP over SMS for login. One day, he can’t make calls or receive messages. Unaware, Ravi continues with his day. Meanwhile, the attacker uses the ‘Forgot Password’ flow in Ravi’s bank app, receives the OTP on the swapped SIM, resets the password, and empties the account before Ravi realizes what happened.”
  • App tampering / emulators: attackers run your APK in instrumented environments to bypass checks or extract secrets.
  • Token theft in transit:If your app is using weak TLS settings, outdated SSL versions, or no encryption at all, a Man-in-the-Middle (MITM) attack can capture these tokens. Once stolen, the attacker can impersonate the user without needing their password or biometric data.

OWASP lists insecure authentication and insecure storage among the top mobile risks — treat those as the baseline to address.

Your system must reduce blast radius: short-lived tokens, device-/key-bound credentials, server verification, and rapid detection/response.

3 — Design goals & trade-offs​


When designing a login and account system, you have to balance several goals — but improving one often means sacrificing another.

Main goals:

  • Security: Keep accounts safe from hackers. This could mean using extra checks like two-step verification (MFA), checking if a device is trusted, and storing passwords securely.
  • Ease of use: People don’t like logging in again and again. They prefer options like “remember me”, logging in with fingerprints/face ID, or using passkeys.
  • Handling growth: Make sure the system can handle millions of users without slowing down. For example, use smart tokens so the server doesn’t have to remember every little detail.
  • Account recovery: If someone loses access, there should be a safe way to get back in — like short-lived reset links or confirming important activity.
  • Privacy: Only collect what’s needed, and always protect it with encryption (so it’s unreadable to anyone who shouldn’t see it).

The trade-off:

  • The more security steps you add, the safer the account — but the harder it becomes for people to log in (and some might give up).
  • The easier you make it, the faster people log in — but you also give attackers more chances to break in.

The smart approach:
Don’t treat all situations the same. For example, only ask for extra checks when something looks suspicious — like logging in from a new country or an unknown device.

4 — Architecture overview​

High-level recap (one-liner)​


A robust mobile auth system issues short-lived access tokens for normal API use and longer-lived refresh tokens for renewing access; tokens are stored and used securely on the device, sensitive actions require extra checks (biometrics / key signing), and a backend risk engine continuously verifies device/app trust and watches for abuse.

Breaking Down Each Step​


1. User Authentication (Entry Point)

The user initiates login through different flows:

  • Email/Password → Standard flow (with backend rate-limiting + reCAPTCHA to avoid brute force).
  • Social Login (Google, Apple, Facebook) → Uses OAuth2, returns an ID token that your backend validates.
  • Passkeys (FIDO2/WebAuthn) → Passwordless login using device biometrics, syncing securely across devices.
  • OTP (One-time Password) → Often SMS/Email, but backend enforces short validity (e.g., 2–5 mins).

📱 Example: In Eventbrite, a user could log in with Google. The Android app gets the Google ID token → sends to Eventbrite backend → backend verifies with Google → issues access_token + refresh_token.

2. Token Issuance

  • Access Token (short-lived, e.g., 15 min — 1 hr)
    Used for all API calls. Even if stolen, it becomes useless after expiry.
  • Refresh Token (long-lived, e.g., 30–90 days)
    Used only to get new access tokens. Usually stored more securely and refreshed on rotation.

🔄 Best Practice: Refresh tokens should rotate — if an attacker tries to reuse an old one, the backend can detect theft.

📱 Example: Spotify mobile app uses a short-lived token for track playback API calls, and silently refreshes in background so users don’t see constant login prompts.

3. Secure Storage

  • Android Keystore → Stores cryptographic keys that never leave the hardware TEE (Trusted Execution Environment).
  • EncryptedSharedPreferences → Stores actual tokens, encrypted with keys from Keystore.
  • Root/Jailbreak Detection → Prevents storage access in unsafe environments.

📱 Example: If someone steals your phone and copies app data via ADB, they still cannot read tokens since Keystore keys are hardware-bound.

4. API Access & Refresh

  • App adds Authorization: Bearer <access_token> header for every API request.
  • If the API returns 401 Unauthorized (expired token), an OkHttp Interceptor (or Retrofit Authenticator) silently refreshes using the refresh_token.
  • If refresh_token is invalid → force logout + re-authentication.

📱 Example: Gmail mobile app never asks you to log in every 30 minutes. Instead, it silently refreshes tokens in the background. If refresh fails (e.g., password changed), you’re prompted to log in again.

5. Sensitive Actions (Step-up Authentication)

Certain actions need re-verification of identity:

  • Changing password
  • Accessing saved payment methods
  • Deleting an account
  • Large transactions

Methods:

  • Biometrics (fingerprint/Face ID)
  • CryptoObject (signs request with a private key unlocked only via biometric)
  • PIN/Pattern fallback

📱 Example: PayPal app → before sending money, it requires fingerprint verification, even if you’re already logged in.

6. Ongoing Trust Verification

The backend doesn’t blindly trust tokens — it evaluates context:

  • Play Integrity API → Detects if the app is running on a rooted device, emulator, or tampered APK.
  • IP-based checks → If a login happens from India and 2 minutes later from Germany → flag suspicious.
  • Rate-limiting & anomaly detection → Stops credential stuffing.

📱 Example: WhatsApp checks if you are logging in from a new device → sends OTP to your phone as an extra safety step.

7. Session Management

Users should be in control:

  • View all active sessions (device + IP + location).
  • Revoke any suspicious sessions (which invalidates tokens server-side).
  • Logout from all devices (rotates refresh tokens globally).

📱 Example: Netflix → lets you see “Manage Devices” → you can kick out old devices if you see something suspicious.

Visually imagine three planes: Client UI + Local Security, Backend Auth Server, Monitoring & Risk Engine (rate-limiting, signals, WAF). Implement each instead of trying to bolt them together ad hoc.

5 — Secure token lifecycle (code + patterns)​

Why tokens?​

  • Access token → a short-lived key that lets the app call APIs.
  • Refresh token → a longer-lived key that can create a new access token without asking the user to log in again.

This way, users don’t have to enter their password every time, and we reduce the risk of exposing credentials.

Where to store them (Android)​


Example: create secure prefs (Kotlin)

// add dependency: implementation "androidx.security:security-crypto:1.1.0"
val masterKey = MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()

val securePrefs = EncryptedSharedPreferences.create(
context,
"secure_prefs",
masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
)

OkHttp interceptor for auto-refresh (Kotlin + coroutines)​


Put this as a global interceptor so 401 responses attempt a refresh, then retry the original request atomically. Most of the applications just shows Generic error messages or log users out, but internally making a auto refresh call can change the game.

class TokenAuthenticator(
private val tokenRepo: TokenRepository,
private val authApi: AuthApi // Retrofit interface for refresh
): Authenticator {

override fun authenticate(route: Route?, response: Response): Request? {
// Avoid multiple simultaneous refreshes:
synchronized(this) {
val currentAccess = tokenRepo.accessToken
if (response.request.header("Authorization") == "Bearer $currentAccess") {
// attempt refresh synchronously (or block on coroutine)
val refreshToken = tokenRepo.refreshToken ?: return null
val refreshResp = runBlocking {
try { authApi.refreshToken(RefreshRequest(refreshToken)) } catch(e:Exception){ null }
}
if (refreshResp?.isSuccessful == true) {
val newTokens = refreshResp.body()!!
tokenRepo.save(newTokens.accessToken, newTokens.refreshToken)
// retry original request with new access token
return response.request.newBuilder()
.header("Authorization", "Bearer ${newTokens.accessToken}")
.build()
} else {
// refresh failed -> must signed out
tokenRepo.clear()
return null
}
} else {
// some other request used a new token already
return null
}
}
}
}

Notes

  • Make sure refresh endpoint is rate-limited to avoid refresh floods.
  • Prefer server-side revocation lists for refresh tokens for logout/invalidate-all logic.

6 — Biometric & key-protected flows (practical, secure)​


A biometric (also called biometric identification) refers to a set of physical or behavioral traits that can be used to uniquely identify a person.

In the world of authentication, a biometric authentication method refers to the process of using someone’s biometric information to identify and grant (or deny) access to accounts and/or resources. Biometric authentication is considered a “what you are” form of ID (as opposed to what you have or what you know).

Note the key difference here: whereas biometrics in general are simply used to confirm someone’s identity, biometric authentication specifically refers to confirming that identity for the purpose of accessing resources online.

Android gives us BiometricPrompt + CryptoObject.
This means you can not only show a fingerprint/face dialog but also bind it to cryptographic operations (like signing a challenge with a private key).

This is the same principle behind passkeys and FIDO2. Super secure and future-proof.

High-level flow (recommendation)​


During registration

  • App generates an asymmetric key pair (private + public key).
  • Private key → safely stored in Android Keystore.
  • Public key → sent to your backend and stored with the user account.

During login/authentication

  • Backend sends a random challenge (a nonce) to the app.
  • App shows a biometric prompt.
  • BiometricPrompt + Keystore private key → sign the challenge.
  • App sends signature back to backend.
  • Backend checks the signature using stored public key.
  • ✅ If valid → user is authenticated, issue session token.

Why this wins​

  • Private key never leaves device.
  • Even if tokens leak, attackers can’t create valid signatures without biometric/device unlock.
  • Compatible with modern standards like passkeys (FIDO/FIDO2).

Key generation (Kotlin)​


fun generateKeyPair(alias: String) {
val kpg = KeyPairGenerator.getInstance(
KeyProperties.KEY_ALGORITHM_RSA, "AndroidKeyStore")
val spec = KeyGenParameterSpec.Builder(
alias,
KeyProperties.PURPOSE_SIGN or KeyProperties.PURPOSE_VERIFY
).apply {
setDigests(KeyProperties.DIGEST_SHA256)
setUserAuthenticationRequired(true)
// optionally .setUserAuthenticationValiditySeconds(...) for a time window
}.build()
kpg.initialize(spec)
kpg.generateKeyPair()
}

Showing BiometricPrompt with CryptoObject​


val cipher = Cipher.getInstance("RSA/ECB/PKCS1Padding")
// init cipher to sign using private key from keystore
cipher.init(Cipher.ENCRYPT_MODE, privateKey)
val cryptoObject = BiometricPrompt.CryptoObject(cipher)

biometricPrompt.authenticate(promptInfo, cryptoObject)

AfteronAuthenticationSucceeded, use result.cryptoObject?.cipher to perform the sign operation and send the signature to the server.

Important: Use the BiometricManager.setAllowedAuthenticators() approach to declare acceptable authenticators (BIOMETRIC_STRONG, DEVICE_CREDENTIAL, etc.). This is the modern API surface to prefer.

I can try covering a whole article on implementing biometric flow in app. Let me know if you would like that.

References​

7 — Device trust: Play Integrity (server + client signals)​


Biometrics and keys solve device-local security, but you still need to know is this app running on a genuine device/not tampered. Use Play Integrity API to get a signed integrity verdict you can verify on the backend before issuing high-risk operations (e.g., accepting payment, ticket validation). (Android Developers)

Client-side (Android):

  • Request an integrity token with Play Integrity SDK.
  • Send token to your backend with any sensitive request.

Server-side:

  • Verify token signature, parse verdict, and decide whether to allow or raise risk score.

When to call it?

  • On sign-in from new device.
  • Before sensitive actions (payments, refunds, scanning tickets).
  • Periodically for long-lived sessions.

Note: Play Integrity helps detect tampered apps, rooted devices, emulators, or installs not from Play. It’s a strong signal in your risk engine. (Android Developers)

8 — Rate limiting, lockouts & anomaly detection​


Server-side (non-negotiable)

  • Rate-limit login attempts per IP and per account (example rule: 5 attempts / 10 minutes per account).
  • Block or escalate after repeated failures (e.g., CAPTCHA, MFA).
  • Breach password checks: compare new passwords against known breached password lists at a set time (or in signup/reset flows).

Client-side UX

  • After several failures, show clear messages: “Too many attempts — try again in 15 minutes” (don’t reveal whether username exists).
  • Local exponential backoff for repeated failures reduces server load and slows attackers.

Monitoring & alerting

  • Log auth events with IP, device, geolocation, and Play Integrity result.
  • Trigger alerts for spikes in failures or unusual patterns (sudden global failures could indicate credential stuffing).

Practical examples​

Example A — Event-ticketing app (real scenario)​


Problem: Attacker uses credential stuffing to buy high-value tickets.
Solution:

  • Block suspicious accounts at checkout if Play Integrity verdict is low.
  • Require biometric re-auth (or passkey) to complete purchase for previously unused devices.
  • Monitor rate of purchases per IP and user.

Example B — Banking app​


Problem: device stolen — attacker can access app if user was logged in.
Solution:

  • Use short access tokens, require biometric unlock for transfers over a threshold, support remote device revocation via account settings, and allow “logout all devices” with server-side refresh token invalidation.

Conclusion​


You now have a concrete, production-minded roadmap: short-lived tokens, secure storage, silent refresh with a robust interceptor, key-protected biometric flows, device trust verification (Play Integrity), and operational controls (rate limits, monitoring). These are the building blocks that move a system from “it works on my device” to “safe for millions of users.”

I hope you like my writing. If you do, follow me on Medium and Linkedin

You can write back to me at [email protected] if you want me to improve something in the upcoming articles. Your feedback is valuable.

Your claps are appreciated to help others find this article 😃 .

0*Te_yNPqNxKuDJf5W.gif

stat



Part 2 — Building a Secure, Practical Authentication System for Android was originally published in ProAndroidDev on Medium, where people are continuing the conversation by highlighting and responding to this story.

Continue reading...
 


Join 𝕋𝕄𝕋 on Telegram
Channel PREVIEW:
Back
Top