The biggest mistake when designing the API behind a mobile app is treating the client as trusted. In reality the device sits in the user's hands, the binary can be decompiled, traffic can be intercepted through a proxy, and any secret embedded in it will surface sooner or later — so mobile backend API security is never complete without rate limiting, token rotation, and server-side validation. This guide builds a production-grade defense line step by step: from the threat model to layered rate limiting, from a short-lived access token + rotating refresh token architecture to stolen-token detection.
💡 Pro Tip: Rate limiting on a single layer (IP only) punishes hundreds of legitimate users behind the same NAT as if they were one client, while letting an authenticated attack slip through unnoticed — always build the layers together as an IP + user + endpoint triad.
Table of Contents
- Threat model: the mobile client is an untrusted client
- Rate limiting layers (IP, user, endpoint) and picking the right window
- Token architecture: short-lived access + rotating refresh
- Detecting a stolen token during rotation (reuse detection)
- Brute-force and account lockout policy
- Removing secrets from the client: the proxy endpoint pattern
- Abuse signals and alert thresholds
- Keeping PII out of logs (KVKK/GDPR)
- FAQ
- How do you set up rate limiting on a mobile app's API?
- How do you implement refresh token rotation?
- Why is storing an API key in a mobile app not secure?
- How do I detect brute-force and abuse on the server?
- Why isn't IP-based rate limiting enough on its own?
- Should I use JWT or an opaque token?
- Conclusion
- Sources
Threat model: the mobile client is an untrusted client
The threat model you're used to when writing a server-side API changes once a mobile client enters the picture. On the web, the browser at least ships with protections like same-origin policy and CSP; on mobile, you've effectively written your own "browser," with no guarantees on top of it.
The app binary runs on the user's device — an attacker can download and decompile it, intercept traffic with tools like mitmproxy or Charles, or even repackage the app to talk to their own servers.
OWASP's "API2:2023 - Broken Authentication" entry in the API Security Top 10 2023 describes this risk precisely: authentication mechanisms are often implemented incorrectly, allowing attackers "to compromise authentication tokens or to exploit implementation flaws." On mobile this shows up specifically as client secrets embedded in the binary and tokens stored insecurely.
RFC 6749 (OAuth 2.0) and RFC 8252 (native app context) classify mobile apps as "public clients" that cannot keep a cryptographic secret confidential; RFC 9700 (OAuth 2.0 Security Best Current Practice) mandates PKCE for this client type: "Public clients MUST use PKCE [RFC7636] to this end, as motivated in Section 4.5.3.1." In practice this means you need to design your backend starting from the assumption that every request coming from the client is suspect until proven otherwise. Rate limiting, token rotation, and server-side validation are the concrete counterparts of that assumption — let's go through them in order.
Rate limiting layers (IP, user, endpoint) and picking the right window
Single-layer rate limiting is almost always insufficient. You need to think about three layers together:
Layer | What it catches | Weak point on its own |
|---|---|---|
IP-based | Heavy traffic from a single source, simple scripts | Thousands of legitimate users behind a mobile carrier's NAT can share the same IP |
User/token-based | Anomalous usage on a single account, credential abuse | Ineffective if the attacker opens a new account on every attempt |
Endpoint-based | Targeting of sensitive endpoints like login, refresh, password reset | Doesn't protect general API traffic, only focuses on critical endpoints |
OWASP's API4:2023 "Unrestricted Resource Consumption" entry emphasizes that rate limiting isn't a single fixed value and needs to be tuned per endpoint: "Rate limiting should be fine tuned based on the business needs. Some API Endpoints might require stricter policies." Endpoints like login or password reset need a much stricter window than a general listing endpoint.
The response returned when the server-side limit is exceeded is also standardized. OWASP's REST Security Cheat Sheet states: "Return 429 Too Many Requests HTTP response code if requests are coming in too quickly." Where you enforce that limit matters too: nginx can't read JWT claims, so it can only own the IP and login layers; you need to implement the user-based layer in the application layer using the verified userId. Setting up the IP and login layers with separate zones in nginx makes it easier to tell which layer triggered from the logs:
1# General IP-based protection2limit_req_zone $binary_remote_addr zone=ip_zone:10m rate=10r/s;3# Stricter, dedicated layer for the login endpoint4limit_req_zone $binary_remote_addr zone=login_zone:10m rate=1r/s;5 6server {7 location /api/auth/login {8 limit_req zone=login_zone burst=3 nodelay;9 limit_req zone=ip_zone burst=5;10 }11 12 location /api/ {13 limit_req zone=ip_zone burst=10;14 }15}I covered how I handle 429 + Retry-After on the client side in Network Layer Optimization.
Choosing the window matters just as much. A fixed-window implementation is simple but carries a "burst" risk at the window boundary: every request allowed in the last second of a minute gets through, and a fresh batch can go through again in the first second of the next minute — roughly doubling the load in a short span. A sliding window or token bucket approach smooths this out, since the limit looks at the last N seconds rather than a fixed clock boundary. I generally prefer the token bucket on critical endpoints (login, refresh); it allows short bursts while keeping the average rate strict. nginx describes this in its own docs as the "leaky bucket" method; its burst parameter delivers the same smoothing effect by allowing short bursts and delaying the excess.
Token architecture: short-lived access + rotating refresh
Two token types on a mobile client serve different roles; the access token is a temporary proof of authorization carried on every request, while the refresh token is a longer-lived but frequently renewed key used in the background to mint new access tokens:
Property | Access token | Refresh token |
|---|---|---|
Lifetime | Short (minutes) | Relatively long, but continuously renewed via rotation |
Where it's stored | In memory / short-lived secure storage | The device's secure storage (Keychain/Keystore) |
Used on every request? | Yes, on every API call | No, only when renewing the access token |
Revocation | Expires on its own when it times out | On reuse detection, the whole family is revoked |
RFC 9700 is explicit for public clients: "Refresh tokens for public clients MUST be sender-constrained or use refresh token rotation as described in Section 4.14." Sender-constraining (like mTLS or DPoP) is a heavy option to set up on mobile; that's why most mobile backends choose the rotation path instead. The same RFC also mandates PKCE for public clients in the authorization code flow — in short, the mobile app is forced to demonstrate "proven ownership" at every step.
If you use JWTs, never skip signature verification. The OWASP REST Security Cheat Sheet warns explicitly: "Ensure JWTs are integrity protected by either a signature or a MAC. Do not allow the unsecured JWTs." Base your trust on what the server verifies, not on what the token claims.
When you replace a refresh token through rotation, tagging every new token with a "family" identifier makes your work easier:
1{2 "familyId": "fam_8f2c1a",3 "tokenId": "tok_3b91",4 "issuedAt": "2025-02-25T10:00:00Z",5 "used": false,6 "previousTokenId": "tok_1a02"7}I covered Keychain, SSL pinning, and jailbreak detection on the device side in iOS Security Best Practices; the rule here is its complement: no matter what the client claims, the server verifies it.
Detecting a stolen token during rotation (reuse detection)
The real power of rotation isn't just renewing the token — it's catching a stolen one. RFC 9700 describes the mechanism: "the authorization server issues a new refresh token with every access token refresh response. The previous refresh token is invalidated, but information about the relationship is retained by the authorization server." The old token is invalidated on every refresh call and a new one issued in its place, but the relationship between them is kept server-side. The next sentence points to the critical signal: "If a refresh token is compromised and subsequently used by both the attacker and the legitimate client, one of them will present an invalidated refresh token, which will inform the authorization server of the breach." In other words, a refresh token presented a second time after it was already used counts as a reuse signal and points to a possible attack.
The RFC first states that the server "will revoke the active refresh token"; the implementation note then adds that the grant a refresh token belongs to may be encoded into the token itself, which lets the server efficiently determine "all refresh tokens that need to be revoked". In practice this is the "family" logic: if a token in a given family is used a second time, ALL tokens in that family — including the active one — are revoked and the user is forced to re-authenticate.
1async function rotateRefreshToken(oldToken: RefreshToken) {2 if (oldToken.used) {3 // Reuse detected: revoke the whole family, raise an alert4 await revokeTokenFamily(oldToken.familyId);5 await emitSecurityAlert("refresh_token_reuse", oldToken.familyId);6 throw new AuthError("token_reuse_detected");7 }8 9 await markTokenUsed(oldToken.tokenId);10 const next = await issueRefreshToken({11 familyId: oldToken.familyId,12 previousTokenId: oldToken.tokenId,13 });14 return next;15}Once this pattern is set up, silently logging reuse alerts isn't enough — they need to be collected and monitored, which the "abuse signals" section below covers.
Brute-force and account lockout policy
The login and password reset endpoints are where rate limiting needs to work hardest. OWASP's "API2:2023 - Broken Authentication" entry describes credential stuffing directly: "Permits credential stuffing where the attacker uses brute force with a list of valid usernames and passwords." In other words, the attacker isn't hunting for a flaw specific to your system; they're trying a username/password list obtained from some other breach.
A layered defense looks like this: endpoint-based rate limiting first (the login_zone above), then an account-based counter, then a temporary lockout. I generally lock the account, not the IP, after a few consecutive failed attempts — locking the IP also punishes legitimate users behind shared networks.
CI/CD discipline is also part of setting up this defense line: to avoid accidentally loosening rate limit rules on every deploy, you can apply the release checklist approach from iOS CI/CD Pipeline to your security rules as well.
Removing secrets from the client: the proxy endpoint pattern
No secret embedded in a mobile binary is safe — obfuscation only delays decompilation, it doesn't prevent it. If you need a third-party API key (payment, maps, an AI provider, doesn't matter which), route it through your own backend rather than the client: the mobile app talks to your API, and your API attaches the secret server-side before forwarding the call.
Two rules from the REST Security Cheat Sheet apply directly here. An HTTP method allowlist: "Apply an allowlist of permitted HTTP Methods e.g. GET, POST, PUT" and reject anything that doesn't match: "Reject all requests not matching the allowlist with HTTP response code 405 Method not allowed." A request size limit: "Define an appropriate request size limit and reject requests exceeding the limit with HTTP response status 413."
1// Simple proxy handler — secret lives only on the server2// Request body size is limited with express.json({ limit: "32kb" }) (413 is triggered there)3app.post(4 "/api/proxy/geocode",5 requireAuth,6 rateLimit("proxy_zone"),7 async (req, res) => {8 const { address } = req.body;9 if (typeof address !== "string" || address.length > 500) {10 return res.status(400).json({ error: "invalid_address_length" });11 }12 const upstream = await fetch(13 `${GEO_PROVIDER_URL}?key=${process.env.GEO_API_KEY}`,14 {15 method: "POST",16 body: JSON.stringify({ address }),17 },18 );19 return res.status(upstream.status).json(await upstream.json());20 },21);Building this pattern with the principles from Backend API with Swift Vapor or REST API Design Principles (in Turkish) in a lightweight backend layer keeps the code you need to change on the mobile side to a minimum — the client still talks to your API, the secret just stays on the server side.
Abuse signals and alert thresholds
Rate limiting and token rotation are passive defense lines; abuse detection requires active monitoring. Worth watching: a large number of 401s (failed authentication) from the same account in a short time, refresh token attempts that trigger a reuse alarm, request volume to a single endpoint far above normal, and geographically inconsistent consecutive logins (e.g. the same account logging in from different continents minutes apart).
Rather than leaving these as individual log lines, collecting events like "refresh_token_reuse" into a centralized security event stream makes it much easier to calibrate alert thresholds later (e.g. consecutive reuse in the same family). The client-side 429 + Retry-After handling I covered in Network Layer Optimization is a good starting point for how these signals show up client-side.
Keeping PII out of logs (KVKK/GDPR)
Security logs are one of the most neglected leak points: writing an access token, password, or email address verbatim to a log while debugging makes every other security measure only as strong as the log file's readability. The REST Security Cheat Sheet gives two clear rules: against log injection, "sanitizing log data beforehand," and "Consider logging token validation errors in order to detect attacks" — meaning log the failures, not the token itself.
A practical rule: before writing a log line, ask whether the field is a credential, a token, or direct personal data. If so, mask it (e.g. only the last 4 characters) or don't log it at all. KVKK and GDPR converge on the same point here: logs also fall under personal data processing and require a retention period and access restrictions.
This is especially easy to forget in error-tracking integrations: a handler that captures the entire request body when an exception is thrown will also capture the password on the login endpoint. The payload sent to your error-tracker and the payload sent to your logs must follow the same redaction rules — otherwise "I cleaned the production log" is a misleading sense of security.
GOLDEN TIP
The most valuable insight in this article
This tip holds the article's most important takeaway.
Easter Egg
You found a hidden gem!
There's a hidden detail in this section. Want to uncover it?
Reader Reward
I've put together everything you should check before moving this guide into a production environment in a single list; each line corresponds to a section we covered in the article, and if you skip one, I recommend going back and rereading that section.
FAQ
How do you set up rate limiting on a mobile app's API?
The most robust approach isn't a single layer but three: general IP-based protection, user/token-based account protection, and a stricter dedicated layer for sensitive endpoints like login/password reset. Since nginx can't read JWT claims, the IP and login layers are set up in nginx with separate limit_req_zone definitions; the user-based layer is enforced in the application layer using the verified userId. A request that exceeds the limit should be answered with 429 Too Many Requests, as OWASP specifies.
How do you implement refresh token rotation?
The old refresh token is invalidated on every refresh call and a new one is issued in its place (RFC 9700). New tokens are associated with a shared "family" identifier; if a token from the same family is used a second time, it's counted as reuse and all tokens in that family are revoked.
Why is storing an API key in a mobile app not secure?
Because the app binary lives on the user's device and can be decompiled; obfuscation only delays this, it doesn't prevent it. Instead, you need to keep the third-party API key on your own backend and set up a proxy pattern where the mobile app talks to your API and your API talks to the third party.
How do I detect brute-force and abuse on the server?
A single metric isn't enough: consecutive failed logins, refresh token reuse alarms, request volume above normal to a single endpoint, and inconsistent geographic login patterns need to be monitored together. Collecting these signals in a centralized security event stream instead of separate log lines makes it easier to calibrate the thresholds later.
Why isn't IP-based rate limiting enough on its own?
Because hundreds of legitimate users can share the same egress IP behind a mobile carrier's NAT; a rule that relies on IP alone either punishes them collectively or is easily bypassed by an attacker who frequently rotates IPs. It needs to work together with user/token-based and endpoint-based layers.
Should I use JWT or an opaque token?
Whichever you choose, don't skip signature/MAC verification — OWASP's REST Security Cheat Sheet is explicit that unsigned JWTs should never be allowed. Opaque tokens require a server-side lookup but are simpler to revoke; JWTs are stateless but need a separate denylist mechanism (the jti claim) for revocation.
Conclusion
Mobile backend API security isn't solved with a single measure; the layers must complement each other. IP + user + endpoint-based rate limiting, a short-lived access token and a rotating refresh token, reuse detection that instantly invalidates stolen tokens, account-based brute-force protection, and secrets removed from the client — together, these form a real defense line.
While building this, you can draw on the client-side network principles from Network Layer Optimization, the backend setup from Backend API with Swift Vapor, and the deploy discipline from iOS CI/CD Pipeline. None is enough alone; together they treat the mobile client as "untrusted but manageable."
Sources
- OWASP REST Security Cheat Sheet — practical rules for rate limiting, HTTP method allowlists, JWTs, and input validation.
- RFC 9700 — OAuth 2.0 Security Best Current Practice — refresh token rotation, sender-constraining, and PKCE requirements.
- OWASP API Security Top 10 2023 — Overview — the full list of the 10 risk categories.
- OWASP API4:2023 — Unrestricted Resource Consumption — the source on why rate limiting needs to be tuned per endpoint.
- OWASP API2:2023 — Broken Authentication — credential stuffing and authentication vulnerabilities.
Tags
iOS Development News
Weekly Swift tips, SwiftUI tricks and iOS best practices. No spam, only valuable content.
We respect your privacy. You can unsubscribe at any time.

