Client Area

Mobile App Auth Strategy — JWT, OAuth, Magic Links, Passkeys

ByDomain India Team·DomainIndia Engineering
7 min readPublished 22 Apr 2026Updated 4 Jun 2026133 views

In this article

  • 1The six methods
  • 2Method 1 — Email + password + JWT
  • 3Method 2 — OAuth / Sign in with Google / Apple
  • 4Google Sign-In (Android)
  • 5Sign in with Apple (iOS)

Mobile App Auth Strategy — JWT, OAuth, Magic Links, Passkeys

TL;DR
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

MethodUser frictionSecurityIndia fitComplexity
Email + password + JWTMediumMediumGoodLow
OAuth (Google/Apple)LowHighGoodMedium
Magic link (email)LowMediumSlow email deliveryLow
OTP via SMSLowMediumGoodMedium (pay per SMS)
OTP via WhatsAppLowMediumExcellentMedium
Passkeys (WebAuthn)LowestHighestGrowingHigh

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:

typescript
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)

kotlin
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)

swift
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 backend

Your backend verifies

typescript
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.

Passwordless via email. Great for low-friction signups.

typescript
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.

typescript
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.

typescript
// 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:

  1. OTP via WhatsApp as primary (lowest friction for India)
  2. Sign in with Google + Apple as social option
  3. Email + password as traditional fallback
  4. 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:

typescript
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:

typescript
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

Q OAuth or OTP for Indian apps?

Both. OTP (WhatsApp) for users who don't use Google often. OAuth for the tech-forward segment.

Q How long should JWT tokens live?

Access: 15-60 min. Refresh: 7-30 days. Passkey session: up to platform OS enforces re-auth.

Q 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.

Q 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.

Q 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

Was this article helpful?

Your feedback helps us improve our documentation

Still need help? Submit a support ticket