Pick the right auth method for your mobile app. This guide compares classic email+password with JWT, OAuth social login, magic links (email), OTP via WhatsApp/SMS, and passkeys (the 2026 password-less future) — with integration code for your DomainIndia-hosted backend.
The six methods
| Method | User friction | Security | India fit | Complexity |
|---|---|---|---|---|
| Email + password + JWT | Medium | Medium | Good | Low |
| OAuth (Google/Apple) | Low | High | Good | Medium |
| Magic link (email) | Low | Medium | Slow email delivery | Low |
| OTP via SMS | Low | Medium | Good | Medium (pay per SMS) |
| OTP via WhatsApp | Low | Medium | Excellent | Medium |
| Passkeys (WebAuthn) | Lowest | Highest | Growing | High |
Most mobile apps in 2026 offer 2-3 methods — users pick. Don't force one.
Method 1 — Email + password + JWT
Classic. Full flow:
Signup: POST /auth/signup { email, password, name }
Login: POST /auth/login { email, password } → { accessToken, refreshToken }
Later: GET /api/me Authorization: Bearer <token>
Refresh: POST /auth/refresh { refreshToken } → { accessToken, refreshToken }Server (Node.js) — hash passwords with bcrypt, issue short-lived JWT:
import bcrypt from 'bcryptjs';
import jwt from 'jsonwebtoken';
app.post('/auth/signup', async (req, res) => {
const { email, password, name } = req.body;
const hash = await bcrypt.hash(password, 12);
const user = await db.user.create({ data: { email, passwordHash: hash, name } });
const tokens = issueTokens(user);
res.json({ user: { id: user.id, email, name }, ...tokens });
});
app.post('/auth/login', async (req, res) => {
const { email, password } = req.body;
const user = await db.user.findUnique({ where: { email } });
if (!user || !await bcrypt.compare(password, user.passwordHash)) {
return res.status(401).json({ error: 'Invalid credentials' });
}
const tokens = issueTokens(user);
res.json({ user: { id: user.id, email: user.email, name: user.name }, ...tokens });
});See our JWT Security Best Practices for the details.
Mobile client storage:
- iOS: Keychain Services
- Android: Keystore
- Never: SharedPreferences (can be extracted with root)
Method 2 — OAuth / Sign in with Google / Apple
On iOS, Apple requires Sign in with Apple if you offer any social login. On Android, Sign in with Google is near-universal.
Google Sign-In (Android)
val gso = GoogleSignInOptions.Builder(GoogleSignInOptions.DEFAULT_SIGN_IN)
.requestIdToken(YOUR_SERVER_CLIENT_ID)
.requestEmail()
.build()
val client = GoogleSignIn.getClient(this, gso)
val intent = client.signInIntent
startActivityForResult(intent, RC_SIGN_IN)
// In onActivityResult:
val task = GoogleSignIn.getSignedInAccountFromIntent(data)
val account = task.getResult(ApiException::class.java)
val idToken = account.idToken
// Send to your backend:
api.googleSignIn(idToken)Sign in with Apple (iOS)
let request = ASAuthorizationAppleIDProvider().createRequest()
request.requestedScopes = [.email, .fullName]
ASAuthorizationController(authorizationRequests: [request]).performRequests()
// In delegate:
let credential = authorization.credential as? ASAuthorizationAppleIDCredential
let tokenData = credential?.identityToken
let idToken = String(data: tokenData!, encoding: .utf8)
// Send to backendYour backend verifies
import { OAuth2Client } from 'google-auth-library';
const client = new OAuth2Client(GOOGLE_CLIENT_ID);
app.post('/auth/google', async (req, res) => {
const ticket = await client.verifyIdToken({
idToken: req.body.idToken,
audience: GOOGLE_CLIENT_ID,
});
const { email, name, sub: googleId } = ticket.getPayload();
let user = await db.user.findUnique({ where: { email } });
if (!user) {
user = await db.user.create({
data: { email, name, googleId, provider: 'google' },
});
}
const tokens = issueTokens(user);
res.json({ user, ...tokens });
});Apple: use apple-signin-auth npm package to verify the Apple JWT against their JWK.
Method 3 — Magic link (email)
Passwordless via email. Great for low-friction signups.
app.post('/auth/magic-link', async (req, res) => {
const { email } = req.body;
let user = await db.user.findUnique({ where: { email } });
if (!user) user = await db.user.create({ data: { email } });
const token = crypto.randomBytes(32).toString('hex');
await redis.setex(`magic:${token}`, 900, user.id); // 15 min
const link = `${FRONTEND_URL}/auth/verify?token=${token}`;
await sendEmail(email, 'Your login link', `
Click to log in: ${link}
Expires in 15 minutes. Didn't request this? Ignore.
`);
res.json({ message: 'Check your email' });
});
app.get('/auth/verify', async (req, res) => {
const userId = await redis.get(`magic:${req.query.token}`);
if (!userId) return res.status(400).send('Expired or invalid link');
await redis.del(`magic:${req.query.token}`);
const user = await db.user.findUnique({ where: { id: userId } });
const tokens = issueTokens(user);
// Return tokens to client somehow (web: cookie; mobile: deep link)
});Issue: email delivery takes 5-30 seconds. Frustrating for users expecting instant.
Method 4 — OTP via WhatsApp (India-favoured)
WhatsApp has 500M+ Indian users. Delivers in <2 seconds vs 30s for email. Much cheaper than SMS (₹0.12 vs ₹0.30 per message).
See our WhatsApp Business API article for setup.
app.post('/auth/otp/send', async (req, res) => {
const { phone } = req.body; // +919876543210
const otp = Math.floor(100000 + Math.random() * 900000).toString();
await redis.setex(`otp:${phone}`, 600, otp);
await sendWhatsApp(phone, 'otp_login', [
{ type: 'body', parameters: [{ type: 'text', text: otp }] },
]);
res.json({ message: 'OTP sent to WhatsApp' });
});
app.post('/auth/otp/verify', async (req, res) => {
const { phone, otp } = req.body;
const stored = await redis.get(`otp:${phone}`);
if (!stored || stored !== otp) {
return res.status(400).json({ error: 'Invalid OTP' });
}
await redis.del(`otp:${phone}`);
let user = await db.user.findUnique({ where: { phone } });
if (!user) user = await db.user.create({ data: { phone } });
const tokens = issueTokens(user);
res.json({ user, ...tokens });
});Rate limit OTP sending — max 3 per phone per hour, max 10 per IP per hour.
Method 5 — Passkeys (WebAuthn) — the future
Passkeys use the device's biometric (FaceID, fingerprint) instead of passwords. Syncs across devices via iCloud / Google Password Manager.
Benefits:
- No passwords to remember, steal, or leak
- Phishing-proof — tied to domain
- Approved by Google, Apple, Microsoft as the future
Adoption in 2026 is growing fast; ~30% of iOS users have passkeys set up.
// Backend — generate challenge
import { generateRegistrationOptions, verifyRegistrationResponse } from '@simplewebauthn/server';
app.post('/auth/passkey/register/begin', async (req, res) => {
const user = await getUser(req);
const options = await generateRegistrationOptions({
rpName: 'Your Company',
rpID: 'yourcompany.com',
userID: user.id,
userName: user.email,
attestationType: 'none',
authenticatorSelection: { userVerification: 'preferred' },
});
await redis.setex(`passkey-challenge:${user.id}`, 300, options.challenge);
res.json(options);
});
app.post('/auth/passkey/register/verify', async (req, res) => {
const user = await getUser(req);
const expectedChallenge = await redis.get(`passkey-challenge:${user.id}`);
const verification = await verifyRegistrationResponse({
response: req.body,
expectedChallenge,
expectedOrigin: 'https://yourcompany.com',
expectedRPID: 'yourcompany.com',
});
if (verification.verified) {
await db.passkey.create({
data: {
userId: user.id,
credentialId: verification.registrationInfo.credentialID,
publicKey: verification.registrationInfo.credentialPublicKey,
},
});
}
res.json({ verified: verification.verified });
});Mobile support: React Native + Flutter have plugins. Swift/Kotlin have native APIs.
Hybrid strategy (real-world recommendation)
Offer:
- OTP via WhatsApp as primary (lowest friction for India)
- Sign in with Google + Apple as social option
- Email + password as traditional fallback
- Passkey setup offered after first login as upgrade
Don't force any single method.
Session management across methods
All methods end the same way — issue access + refresh token. Backend treats them identically post-auth.
Track auth method for analytics:
await db.loginEvent.create({
data: { userId, method: 'whatsapp_otp', ip: req.ip, userAgent: req.headers['user-agent'] },
});Rate limiting (critical)
Every auth endpoint is brute-forcable. Rate limit:
import rateLimit from 'express-rate-limit';
const loginLimit = rateLimit({
windowMs: 15 * 60 * 1000, max: 10,
message: 'Too many attempts',
});
app.post('/auth/login', loginLimit, loginHandler);For OTPs, also rate limit per phone number (not just IP).
Common pitfalls
FAQ
OAuth or OTP for Indian apps?
Both. OTP (WhatsApp) for users who don't use Google often. OAuth for the tech-forward segment.
How long should JWT tokens live?
Access: 15-60 min. Refresh: 7-30 days. Passkey session: up to platform OS enforces re-auth.
Biometric auth in the app — isn't that the same as passkeys?
App biometric unlocks local data (e.g. stored refresh token). Passkeys are a server-verified credential. Different layers.
Can I use Firebase Auth + custom backend?
Yes — Firebase Auth issues ID tokens; your backend verifies via firebase-admin. Saves you implementing auth but adds Firebase dependency.
Sign in with Email only (no password)?
That IS magic links. Valid pattern; just slow for users waiting on email.
Build multi-method mobile auth on a DomainIndia backend. See our VPS plans