Backend APIs for Mobile Apps

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

By Domain India Team · DomainIndia EngineeringPublished 11 min read
Knowledge base article
Contents (13 sections)

How users sign in to your mobile app affects sign-up rates, support load and security. This guide compares email and password with JWTs, Google and Apple sign-in, magic links, OTP over SMS or WhatsApp, and passkeys, with backend code you can adapt, and notes on hosting that backend at Domain India.

Key takeaways

Offer two or three sign-in methods rather than forcing one. For Indian users, phone OTP (WhatsApp or SMS) plus Google and Apple sign-in covers most people, with passkeys offered after the first login as a phishing-resistant upgrade. Whatever the method, finish by issuing the same short-lived access token and rotating refresh token, store tokens in the Keychain or Keystore, and rate-limit every auth endpoint.

1. The six methods

MethodUser frictionSecurityIndia fitComplexity
Email + password + JWTMediumMediumGoodLow
OAuth (Google / Apple)LowHighGoodMedium
Magic link (email)LowMediumEmail can be slowLow
OTP via SMSLowMediumGood (DLT registration needed)Medium, paid per SMS
OTP via WhatsAppLowMediumVery goodMedium, paid per message
Passkeys (WebAuthn)LowestHighestGrowingHigh

Most apps offer two or three of these and let users pick.

2. Email + password + JWT

The classic flow:

code
Signup:  POST /auth/signup  { email, password, name }
Login:   POST /auth/login   { email, password } → { accessToken, refreshToken }
Later:   GET  /api/me       Authorization: Bearer <accessToken>
Refresh: POST /auth/refresh { refreshToken } → { accessToken, refreshToken }

Server (Node.js): hash passwords with bcrypt or argon2, and issue a short-lived access token:

typescript
import bcrypt from 'bcryptjs';

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 token lifetimes, refresh rotation and revocation.

Where the app stores tokens:

  • iOS: the Keychain
  • Android: encrypt with a key held in the Android Keystore
  • Never: plain SharedPreferences, AsyncStorage or files, which can be read on rooted devices and from backups

3. OAuth: Sign in with Google and Apple

Apple's App Store rules say that if your iOS app offers a third-party login such as Google, it must also offer a privacy-focused login option; Sign in with Apple meets that requirement. On Android, Google sign-in is expected by most users.

Google sign-in (Android, Credential Manager)

The older GoogleSignIn API is deprecated. Use Credential Manager:

kotlin
val googleIdOption = GetGoogleIdOption.Builder()
    .setServerClientId(WEB_CLIENT_ID)   // your backend's OAuth client ID
    .setFilterByAuthorizedAccounts(false)
    .build()

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

// Inside a coroutine
val result = CredentialManager.create(context).getCredential(context, request)
val credential = result.credential
if (credential is CustomCredential &&
    credential.type == GoogleIdTokenCredential.TYPE_GOOGLE_ID_TOKEN_CREDENTIAL) {
    val idToken = GoogleIdTokenCredential.createFrom(credential.data).idToken
    api.googleSignIn(idToken)   // send to your backend
}

Sign in with Apple (iOS)

swift
let request = ASAuthorizationAppleIDProvider().createRequest()
request.requestedScopes = [.email, .fullName]
let controller = ASAuthorizationController(authorizationRequests: [request])
controller.delegate = self
controller.performRequests()

// In the delegate:
guard let credential = authorization.credential as? ASAuthorizationAppleIDCredential,
      let tokenData = credential.identityToken,
      let idToken = String(data: tokenData, encoding: .utf8) else { return }
// Send idToken to your backend

Apple sends the user's name only on the first sign-in, so save it then.

Your backend verifies the token

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, email_verified, name, sub: googleId } = ticket.getPayload()!;
  if (!email_verified) return res.status(401).json({ error: 'Email not verified' });

  let user = await db.user.findUnique({ where: { googleId } })
          ?? 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 });
});

For Apple, verify the identity token's signature against Apple's published keys (the apple-signin-auth package does this), and check the audience matches your app's bundle ID.

Passwordless sign-in by email. Low friction for sign-ups.

typescript
import crypto from 'node:crypto';
const sha256 = (s: string) => crypto.createHash('sha256').update(s).digest('hex');

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');
  // Store only a hash of the token, valid for 15 minutes
  await db.loginToken.create({
    data: { hash: sha256(token), userId: user.id, expiresAt: new Date(Date.now() + 15 * 60_000) },
  });
  const link = `${APP_LINK_URL}/auth/verify?token=${token}`;
  await sendEmail(email, 'Your sign-in link',
    `Tap to sign in: ${link}\nThe link expires in 15 minutes. If you didn't ask for it, ignore this email.`);

  res.json({ message: 'Check your email' });
});

app.post('/auth/verify', async (req, res) => {
  const row = await db.loginToken.findUnique({ where: { hash: sha256(req.body.token) } });
  if (!row || row.expiresAt < new Date()) return res.status(400).json({ error: 'Expired or invalid link' });
  await db.loginToken.delete({ where: { hash: row.hash } });   // one use only
  const user = await db.user.findUnique({ where: { id: row.userId } });
  res.json(issueTokens(user));
});

Use a universal link (iOS) or app link (Android) so the email opens your app, and have the app POST the token. Don't consume the token on a plain GET: email security scanners open links before the user does. The drawback of magic links is email delivery, which can take a while and sometimes lands in spam.

5. OTP via WhatsApp or SMS

WhatsApp is used by a very large share of Indian smartphone users and usually delivers in seconds. Meta charges per authentication message; check its current rate card for India. SMS OTP in India requires DLT registration of your sender ID and message template with a telecom operator. See our WhatsApp Business API article for setup.

typescript
import crypto from 'node:crypto';

app.post('/auth/otp/send', otpSendLimit, async (req, res) => {
  const { phone } = req.body;   // +919876543210
  const otp = crypto.randomInt(100000, 1000000).toString();   // never Math.random()
  await otpStore.set(phone, { hash: sha256(otp), attempts: 0 }, { ttlSeconds: 600 });

  await sendWhatsApp(phone, 'otp_login', [
    { type: 'body', parameters: [{ type: 'text', text: otp }] },
  ]);
  res.json({ message: 'OTP sent' });
});

app.post('/auth/otp/verify', async (req, res) => {
  const { phone, otp } = req.body;
  const entry = await otpStore.get(phone);
  if (!entry || entry.attempts >= 5) return res.status(400).json({ error: 'Invalid OTP' });

  const ok = crypto.timingSafeEqual(Buffer.from(entry.hash), Buffer.from(sha256(otp)));
  if (!ok) {
    await otpStore.incrementAttempts(phone);
    return res.status(400).json({ error: 'Invalid OTP' });
  }
  await otpStore.delete(phone);

  let user = await db.user.findUnique({ where: { phone } });
  if (!user) user = await db.user.create({ data: { phone } });
  res.json({ user, ...issueTokens(user) });
});

otpStore can be Redis or a database table with an expiry column. Limit sends to about 3 per phone number per hour and 10 per IP address per hour, and cap verification attempts per code.

6. Passkeys (WebAuthn)

Passkeys replace passwords with a key pair unlocked by the device's biometrics or screen lock, synced through iCloud Keychain or Google Password Manager.

Benefits:

  • Nothing to remember, reuse or leak
  • Phishing-resistant: a passkey only works for the domain it was created for
  • Supported by Apple, Google and Microsoft platforms
typescript
import { generateRegistrationOptions, verifyRegistrationResponse } from '@simplewebauthn/server';
import { isoUint8Array } from '@simplewebauthn/server/helpers';

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: isoUint8Array.fromUTF8String(user.id),
    userName: user.email,
    attestationType: 'none',
    authenticatorSelection: { residentKey: 'preferred', userVerification: 'preferred' },
  });
  await challengeStore.set(user.id, options.challenge, { ttlSeconds: 300 });
  res.json(options);
});

app.post('/auth/passkey/register/verify', async (req, res) => {
  const user = await getUser(req);
  const expectedChallenge = await challengeStore.take(user.id);   // read and delete
  const verification = await verifyRegistrationResponse({
    response: req.body,
    expectedChallenge,
    expectedOrigin: EXPECTED_ORIGINS,   // your web origin, plus Android app origins
    expectedRPID: 'yourcompany.com',
  });
  if (verification.verified && verification.registrationInfo) {
    const { credential } = verification.registrationInfo;
    await db.passkey.create({
      data: {
        userId: user.id,
        credentialId: credential.id,
        publicKey: Buffer.from(credential.publicKey),
        counter: credential.counter,
      },
    });
  }
  res.json({ verified: verification.verified });
});

For native apps, the platform checks that your app is allowed to use passkeys for your domain. Publish /.well-known/apple-app-site-association (iOS) and /.well-known/assetlinks.json (Android) on your domain over HTTPS. Android native apps send an android:apk-key-hash: origin, which you add to the expected origins. iOS uses the Authentication Services framework; Android uses Credential Manager; React Native and Flutter have plugins that wrap both.

7. A practical mix for Indian apps

  1. Phone OTP as the main route.
    WhatsApp first, SMS as a fallback. Lowest friction for most users.
  2. Google and Apple sign-in.
    For users who prefer them, and to meet Apple's login rule.
  3. Email and password.
    A traditional fallback for users without a phone login.
  4. Passkeys after the first login.
    Offer them as an upgrade once the user is signed in.

Every method should end the same way: issue an access token and a refresh token, so the rest of the backend doesn't care how the user signed in. Record the method for analytics:

typescript
await db.loginEvent.create({
  data: { userId, method: 'whatsapp_otp', ip: req.ip, userAgent: req.headers['user-agent'] },
});

8. Rate limiting

Every auth endpoint can be brute-forced. Rate-limit them:

typescript
import rateLimit from 'express-rate-limit';

const loginLimit = rateLimit({ windowMs: 15 * 60 * 1000, limit: 10, message: 'Too many attempts' });
app.post('/auth/login', loginLimit, loginHandler);

For OTPs, also limit per phone number, not just per IP. If you run several app processes, use a shared store (Redis or your database) for the counters.

9. Common pitfalls

Passwords unhashed or hashed with MD5 or SHA-1
A breach exposes every account. Use bcrypt or argon2.
OTPs or tokens in logs
Request logs often capture them. Never log secrets, and store only hashes.
Weak random numbers
Math.random() is predictable. Use crypto.randomInt and crypto.randomBytes.
No expiry or attempt limit
Keep OTPs to 10 minutes or less, single use, with a few attempts per code.
Trusting unverified ID tokens
Verify Google and Apple tokens server-side, check the audience, and require a verified email before linking accounts by email.
Passkeys with no fallback
Some users and devices can't use them yet. Phase them in alongside another method.

10. Hosting your auth backend at Domain India

  • Shared hosting (cPanel, DirectAdmin): has Node.js and Python app tools, so a simple request/response auth API can run there. Redis is not available, so keep OTPs, challenges and login tokens in a MySQL table with an expiry column. Sending mail over SMTP from your app does not work on shared hosting; PHP apps should use mail() with -f, and other apps an HTTPS email API (test it on your plan). Free SSL is included, which passkeys and OAuth redirects need.
  • App Platform: Node.js apps are detected automatically (other languages need a Dockerfile), with PostgreSQL and free SSL on every plan. There is no Redis, so use PostgreSQL for OTP and token storage.
  • VPS: self-managed with full root access, for Redis-backed rate limiting, background senders and anything else you need. Plans start from ₹553 a month, excluding 18% GST (Domain India list price on 30 September 2026). No backups are included, so back up your user database yourself.
OAuth or OTP for an Indian app?

Offer both. Phone OTP over WhatsApp or SMS suits users who don't want to use a Google account, and Google or Apple sign-in suits users who prefer one tap. Let people choose.

How long should JWTs live?

Access tokens: 15 to 60 minutes. Refresh tokens: 7 to 30 days, rotated on every use, with the old one revoked, so a stolen refresh token is detected when it is reused.

Is in-app biometric unlock the same as passkeys?

No. App biometric unlock protects data on the device, such as a stored refresh token. A passkey is a credential your server verifies with public-key cryptography. They work at different layers and can be combined.

Can I use Firebase Auth with my own backend?

Yes. Firebase Auth issues ID tokens that your backend verifies with the Firebase Admin SDK. It saves you building the auth flows yourself, at the cost of depending on Firebase.

Is email-only sign-in without a password a good idea?

That is the magic-link pattern, and it is valid. Its weakness is email delivery time and spam filtering, so many apps pair it with a short numeric code the user can type instead.

Can I store OTPs in Redis on Domain India shared hosting?

No. Redis is not available on Domain India shared hosting or the App Platform. Store OTPs in a database table with an expiry time, or run Redis on a Domain India VPS.

Ready to build your auth backend? Start a Node.js API on cPanel hosting, deploy it on the App Platform, or take full control on a VPS.

Host your app's backend

Full root access for Redis-backed rate limiting, OTP senders and your auth API.

See VPS plans

Was this article helpful?

Your answer helps us decide what to improve next.

Still need help? Open a support ticket and our team will reply.

Prefer an app? Add this site to your home screen.Get the app