All Articles
CategorySecurity
Reading Time
13 min read
Published
2025-02-25
Word Count
3,312words

Grab a coffee — this one is a deep dive!

Mobile Backend API Security: Rate Limits & Token Rotation

Summary

A guide to building a production-grade defense line for mobile backend API security — layered rate limiting, short-lived access tokens, refresh token rotation, and reuse detection.

  • Set up rate limiting not as a single layer but as an IP + user + endpoint triad; give sensitive endpoints (login, refresh) a stricter window.
  • Keep the access token short-lived and rotate the refresh token on every use (rotation) — so a stolen token doesn't stay valid forever.
  • If a refresh token is used a second time, count it as reuse and revoke every token from the same family.
  • Don't embed third-party API keys in the mobile binary; keep the secret on the server behind a proxy endpoint that goes through your own backend.
Mobile Backend API Security: Rate Limits & Token Rotation

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

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:

nginx
1# General IP-based protection
2limit_req_zone $binary_remote_addr zone=ip_zone:10m rate=10r/s;
3# Stricter, dedicated layer for the login endpoint
4limit_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:

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

ts
1async function rotateRefreshToken(oldToken: RefreshToken) {
2 if (oldToken.used) {
3 // Reuse detected: revoke the whole family, raise an alert
4 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."

ts
1// Simple proxy handler — secret lives only on the server
2// 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

Tags

#API security#rate limiting#token rotation#OAuth 2.0#backend#mobile security#OWASP
Muhittin Çamdalı

Muhittin Çamdalı

Lead Mobile Engineer

Lead Mobile Engineer with 12+ years of experience. Expert in iOS, Android and cross-platform architectures with Swift, SwiftUI, Kotlin and Flutter. I build performant, user-friendly mobile apps.

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.

Share