Backend Security Best Practices for Mobile Apps (With Code You Can Actually Use)
Mobile developers spend a lot of time on the app and a lot less on the server behind it. That's understandable: the app is what users see, and the backend is "just an API." But here's the uncomfortable truth: your mobil
Mobile developers spend a lot of time on the app and a lot less on the server behind it. That's understandable: the app is what users see, and the backend is "just an API."
But here's the uncomfortable truth: your mobile app is not a security boundary. Anyone can download it, decompile it, intercept its traffic, and call your API directly with curl. Whatever your app "prevents" in the UI, an attacker can simply skip.
So the real security of a mobile product lives in the backend. This article walks through the practices that matter most, with code examples in Node.js/TypeScript (Express). The principles apply to any language or framework.
The mindset: assume the client is hostile
Before any code, adopt this rule:
Never trust anything that comes from the client: not the request body, not the headers, not the "user ID" field, not the app version, not "this button is hidden for free users."
Everything below follows from it. If a check only exists in the app, it doesn't exist.
Most real-world API breaches don't come from exotic cryptography attacks. They come from boring mistakes: missing authorization checks, leaked secrets, no rate limiting, and over-trusting input. The OWASP API Security Top 10 is a good checklist, and the top items are consistently authorization failures and broken authentication.
- Always use TLS, and know what it does and doesn't give you
HTTPS is non-negotiable. It protects data in transit from passive eavesdropping and tampering on public Wi-Fi and hostile networks.
Practical rules:
Enforce HTTPS everywhere and redirect or reject plain HTTP.
Use a modern TLS configuration from your host or load balancer rather than hand-rolling one.
Send an HSTS header so clients don't downgrade.
ts
import express from 'express';
import helmet from 'helmet';
const app = express();
// Helmet sets several protective headers, including HSTS
app.use(helmet());
app.disable('x-powered-by');
About certificate pinning: it can protect against some man-in-the-middle scenarios, but it's a trade-off. If you pin incorrectly or rotate certificates carelessly, you can lock out your own users with a broken app until they update. If you pin, pin to something you can rotate safely, include backup pins, and have a kill-switch plan. Also know that a determined attacker with a rooted device can often bypass pinning, so treat it as defense-in-depth, not a guarantee.
- Authentication: don't invent your own
If you can, use a proven identity provider (Auth0, Firebase Auth, AWS Cognito, Keycloak, or your platform's equivalent) instead of building password handling yourself. It's not that you can't build it. It's that the edge cases (account recovery, brute force, MFA, session revocation) are where homemade systems fail.
If you do handle passwords yourself, use a slow, memory-hard password hashing algorithm such as Argon2id (or bcrypt/scrypt). Never store plain text, and never use fast hashes like plain SHA-256 or MD5 for passwords.
ts
import argon2 from 'argon2';
export async function hashPassword(password: string): Promise {
return argon2.hash(password, { type: argon2.argon2id });
}
export async function verifyPassword(hash: string, password: string): Promise {
return argon2.verify(hash, password);
}
Extra habits that pay off:
Return the same generic error for "wrong email" and "wrong password" so you don't reveal which accounts exist.
Support MFA for sensitive apps (finance, health, admin tools).
Check new passwords against known-breached password lists if you can.
Make login, signup, and password reset the most rate-limited endpoints in your API.
- Tokens: short-lived access, rotating refresh
Mobile apps typically use a short-lived access token plus a longer-lived refresh token. Done right, this limits the damage if a token leaks.
Access tokens
Keep them short-lived (minutes, not days). If you use JWTs, always specify the allowed algorithm when verifying, and never accept alg: none or let the token choose its own algorithm.
ts
import jwt from 'jsonwebtoken';
const ACCESS_SECRET = process.env.JWT_ACCESS_SECRET!; // from a secrets manager, not source code
export function signAccessToken(userId: string) {
return jwt.sign({ sub: userId }, ACCESS_SECRET, {
algorithm: 'HS256',
expiresIn: '15m',
issuer: 'api.example.com',
audience: 'mobile-app',
});
}
export function verifyAccessToken(token: string) {
return jwt.verify(token, ACCESS_SECRET, {
algorithms: ['HS256'], // pin the algorithm
issuer: 'api.example.com',
audience: 'mobile-app',
});
}
Refresh token rotation
Refresh tokens are powerful, so treat them like passwords:
Generate them with a cryptographically secure random generator.
Store only a hash of the token on the server.
Rotate on every use: issue a new refresh token and invalidate the old one.
If an old token is ever reused, assume theft and revoke the whole session family.
ts
import crypto from 'crypto';
function newRefreshToken() {
return crypto.randomBytes(48).toString('base64url');
}
function hashToken(token: string) {
return crypto.createHash('sha256').update(token).digest('hex');
}
export async function rotateRefreshToken(oldToken: string) {
const oldHash = hashToken(oldToken);
const record = await db.refreshTokens.findByHash(oldHash);
if (!record) throw new Error('invalid_token');
if (record.usedAt) {
// Token reuse detected: someone may have stolen it.
await db.refreshTokens.revokeFamily(record.familyId);
throw new Error('token_reuse_detected');
}
await db.refreshTokens.markUsed(record.id);
const fresh = newRefreshToken();
await db.refreshTokens.create({
userId: record.userId,
familyId: record.familyId,
tokenHash: hashToken(fresh),
expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
});
return { accessToken: signAccessToken(record.userId), refreshToken: fresh };
}
(The db object here is pseudocode for your data layer.)
On the device
Where the app stores tokens matters too. Use the platform's secure storage rather than plain preferences or local files:
iOS: Keychain
Android: EncryptedSharedPreferences or the Android Keystore
Flutter and React Native have wrappers around these (for example flutter_secure_storage or Keychain/Keystore-backed libraries for React Native).
And give users a way to log out everywhere, which means server-side revocation, not just deleting the token locally.
- Authorization: the bug that causes the most breaches
Authentication answers "who are you?" Authorization answers "are you allowed to do this, to this object?" The second question is the one teams forget.
The classic bug is BOLA / IDOR (Broken Object Level Authorization): the API trusts an ID from the request and returns whatever it points to.
Vulnerable:
ts
// Any logged-in user can read ANY order by guessing IDs
app.get('/orders/:id', requireAuth, async (req, res) => {
const order = await pool.query('SELECT * FROM orders WHERE id = $1', [req.params.id]);
res.json(order.rows[0]);
});
Fixed: always scope the query to the authenticated user (or check ownership explicitly):
ts
app.get('/orders/:id', requireAuth, async (req, res) => {
const result = await pool.query(
'SELECT * FROM orders WHERE id = $1 AND user_id = $2',
[req.params.id, req.user.id]
);
if (result.rowCount === 0) {
// Same response whether it doesn't exist or isn't yours
return res.status(404).json({ error: 'not_found' });
}
res.json(result.rows[0]);
});
Rules to live by:
Derive identity from the token, never from the request body. If your signup or update endpoint accepts userId or role in JSON, an attacker will set it.
Check authorization on every endpoint and every object, not just at login.
Watch for mass assignment: don't blindly spread request bodies into database updates.
Use random, non-guessable IDs (UUIDs) as defense-in-depth, but never as a replacement for real checks.
Keep admin and privileged functions on separately protected routes and test them explicitly.
ts
// Don't do this: attacker can send { "role": "admin" }
await db.users.update(req.user.id, req.body);
// Do this: allow-list the fields users may change
const { displayName, avatarUrl } = req.body;
await db.users.update(req.user.id, { displayName, avatarUrl });
- Validate input, and validate it on the server
Client-side validation is for user experience. Server-side validation is for security. Use a schema library so validation is consistent and hard to forget.
ts
import { z } from 'zod';
const SignupSchema = z.object({
email: z.string().email().max(254),
password: z.string().min(12).max(128),
displayName: z.string().min(1).max(60),
});
app.post('/signup', async (req, res) => {
const parsed = SignupSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({ error: 'invalid_input' });
}
// parsed.data is now typed and constrained
// ...create the user
});
And for databases, always use parameterized queries. Never build SQL by concatenating strings.
ts
// Vulnerable to SQL injection
const sql = SELECT * FROM users WHERE email = '${email}';
// Safe: parameterized
await pool.query('SELECT * FROM users WHERE email = $1', [email]);
The same principle applies to NoSQL queries, shell commands, file paths, and any template or interpreter. If untrusted input reaches an interpreter as code, you have an injection bug.
Other input-related habits:
Set maximum body sizes and timeouts so one request can't exhaust your server.
Validate file uploads: type, size, and content, and store them outside the web root or in object storage with restricted access. Never trust the filename or the client-reported MIME type.
Whitelist URLs if your server fetches user-supplied links, to avoid SSRF (server-side request forgery) against your internal network or cloud metadata endpoints.
- Rate limiting and abuse protection
Without limits, your API is a free brute-force and scraping service.
ts
import rateLimit from 'express-rate-limit';
// General API limit
const apiLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
limit: 300,
standardHeaders: 'draft-7',
legacyHeaders: false,
});
// Much stricter for sensitive endpoints
const authLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
limit: 10,
standardHeaders: 'draft-7',
legacyHeaders: false,
});
app.use('/api', apiLimiter);
app.use('/api/login', authLimiter);
app.use('/api/password-reset', authLimiter);
Notes from the field:
Limit by user and IP, and use a shared store (like Redis) if you run multiple server instances. In-memory counters don't work across instances.
Add progressive delays or temporary lockouts after repeated failed logins, but be careful that lockouts can't be weaponized to lock out real users.
Put a WAF or CDN in front of the API for volumetric abuse.
For high-value actions (signup, checkout, SMS/OTP sending), consider bot protection or CAPTCHA, and watch your SMS costs: OTP endpoints without limits are a known way to run up bills.
- Secrets: nothing sensitive in the app, nothing sensitive in git
This one is simple and violated constantly.
Anything you ship inside the mobile app can be extracted. That includes API keys, "obfuscated" strings, and hardcoded credentials. Obfuscation slows attackers down; it doesn't stop them.
So:
Don't put private keys, database credentials, or third-party secret keys in the app. Have the app call your backend, and let the backend call the third party.
Some keys are designed to be public (for example, many map or analytics keys). For those, restrict them by app identifier, bundle ID, and API scope in the provider console.
Keep server secrets in environment variables loaded from a secrets manager (AWS Secrets Manager, GCP Secret Manager, Vault, or your host's equivalent), not committed to the repository.
Add secret scanning to your repo and CI so accidental commits get caught. If a secret ever lands in git history, rotate it: deleting the commit isn't enough.
ts
// Fail fast at startup if a required secret is missing
function requireEnv(name: string): string {
const value = process.env[name];
if (!value) throw new Error(Missing required env var: ${name});
return value;
}
const JWT_ACCESS_SECRET = requireEnv('JWT_ACCESS_SECRET');
- Verify webhooks and third-party callbacks
Payment providers, subscription systems, and messaging services call your server with webhooks. If you don't verify them, anyone can POST a fake "payment succeeded" event.
Verify the provider's signature using the raw request body and a constant-time comparison:
ts
import crypto from 'crypto';
function verifySignature(rawBody: Buffer, signatureHeader: string, secret: string) {
const expected = crypto
.createHmac('sha256', secret)
.update(rawBody)
.digest('hex');
const a = Buffer.from(expected);
const b = Buffer.from(signatureHeader);
// timingSafeEqual requires equal lengths
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
(Use your provider's official SDK helper if it has one. Signature formats differ between providers, and the SDK handles details like timestamps and replay protection.)
The same logic applies to in-app purchases and subscriptions: validate receipts and entitlements on the server with the store's APIs. If the app alone decides "this user is premium," a modified client can decide that too.
- Don't leak information in errors or logs
Two opposite mistakes:
Too verbose errors that return stack traces, SQL errors, or internal paths to the client.
Too little logging, so when something goes wrong, you can't tell what happened.
Return generic errors to clients and keep the details in server logs:
ts
app.use((err: Error, req: express.Request, res: express.Response, _next: express.NextFunction) => {
// Detailed log for you (with a request ID for correlation)
logger.error({ err, requestId: req.id, path: req.path });
// Generic message for the client
res.status(500).json({ error: 'internal_error', requestId: req.id });
});
Logging guidelines:
Never log passwords, full tokens, card numbers, or sensitive personal data.
Log security-relevant events: failed logins, permission denials, token reuse, password changes, admin actions.
Set up alerts on suspicious patterns (spikes in 401/403, many failed logins from one source).
Know your data retention and privacy obligations (for example GDPR/CCPA) before deciding what to store and for how long.
- Protect the supply chain and the infrastructure
Your code might be perfect while a dependency or a misconfigured cloud bucket is not.
Dependencies: run automated audits (npm audit, Dependabot/Renovate, or similar) and update regularly. Pin versions with a lockfile and review what you add.
Least privilege: the database user your API uses shouldn't be a superuser. Cloud roles should have only the permissions they need.
Network: keep databases in private networks, not exposed to the public internet.
Storage: check that cloud buckets are private by default. Public buckets with user data are a repeat headline.
Backups: encrypt them, test restores, and restrict who can access them.
Environments: keep dev, staging, and production separate, with separate credentials.
CI/CD: protect your pipeline credentials. A compromised pipeline can ship malicious code under your name.
- Device and app attestation (helpful, not magic)
You can't fully verify that a request comes from your genuine, unmodified app. But platform attestation services let your backend ask for a signal that raises the bar:
Android: Play Integrity API
iOS: App Attest / DeviceCheck
These can help reduce automated abuse, fake clients, and emulator farms, particularly for signups, promotions, and payments. Use them as one signal among several, combined with rate limiting, anomaly detection, and server-side rules. Don't rely on them as the only gate.
A quick API security checklist
Copy this into your project:
Transport & headers
HTTPS enforced, HSTS enabled
Security headers set (e.g., via Helmet or your gateway)
Authentication
Passwords hashed with Argon2id/bcrypt/scrypt (or delegated to an identity provider)
Short-lived access tokens, rotating refresh tokens stored hashed
JWT algorithm pinned; issuer/audience checked
Server-side logout and session revocation
Authorization
Every endpoint checks the user's permission for the specific object
Identity taken from the token, never the request body
Field allow-lists for updates (no mass assignment)
Input & data
Schema validation on all inputs
Parameterized queries only
File upload validation and safe storage
SSRF protections on any server-side URL fetching
Abuse & monitoring
Rate limits, with stricter ones on auth and OTP endpoints
Security event logging and alerting
No sensitive data in logs or error responses
Secrets & infrastructure
No private secrets in the app or in git; secret scanning on
Webhooks verified; purchases validated server-side
Dependencies audited; least-privilege roles; private databases and buckets
Final thoughts
You don't need to do everything on day one. If you're short on time, focus on the highest-impact items first:
Authorization on every object (stops the most common real-world data leaks)
No secrets in the app or repo
Server-side validation and parameterized queries
Rate limiting on login and sensitive endpoints
Short-lived tokens with proper refresh handling
Security is not a feature you finish. It's a habit: review it when you add endpoints, test it before release, and revisit it as your app grows. And remember, the app is just a client. The backend is where your users' trust is actually kept.
If you found this useful, I'd love to hear which of these you've seen go wrong in the wild. Share your story in the comments.
This article was originally published on the Koderspedia blog. Koderspedia builds mobile apps, games, and web products for startups and enterprises. Learn more at koderspedia.com.
Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.