6 min read

Why Embedded API Keys Cannot Authenticate Mobile Apps

Why a shared key inside a mobile app cannot prove client identity, and how authentication, authorization, PKCE, attestation, and monitoring fit together.

  • Architecture
  • Security
  • Reliability

I once assumed that an API used by a mobile app should reject requests opened from a browser. My first solution was simple: require a custom X-App-Key header and reject requests that did not ask for JSON.

It looked effective during a quick test. A browser request failed, while the mobile app continued to work. The problem was that I had only made the request less convenient to reproduce. I had not proved who sent it.

In the Laravel API that led to this article, I stopped treating the shared app key as identity. I protected user-specific routes with user authentication and server-side authorization instead. The other controls in this article came from researching the boundaries around that change; they are not a checklist that I applied to one application.

A Copied Header Is Not Identity

An HTTP API receives a method, URL, headers, and body. It does not know whether those values came from Chrome, Postman, cURL, or a mobile application. A caller can reproduce the same request from another client:

curl https://api.example.com/products \
  -H "Accept: application/json" \
  -H "X-App-Key: copied-from-the-mobile-app"

Laravel’s expectsJson() is useful for deciding how to format a response, but it does not authenticate the caller. A custom header only proves that someone knows its value.

The same limitation applies to a key embedded in an Android or iOS application. The application is distributed to users, so its keys, URLs, headers, constants, and request logic can eventually be inspected. Obfuscation can make inspection harder, but it cannot give a distributed application a server-side secret.

An embedded key may still be useful for low-risk purposes such as identifying an app build or assigning a quota. In that case, I would limit its permissions and plan for it to be copied. I would not use it as proof of a user or device identity.

Protect the User and the Resource

The question that helped me choose the right control was not which client sent the request, but what the endpoint protected:

  • Is the endpoint public?
  • Does it act for a signed-in user?
  • Which records may that user access?
  • Is the caller another server that can keep a credential private?
  • Is the main concern identity or automated abuse?

For user-specific routes in a first-party Laravel application, Sanctum can issue revocable tokens. Authentication middleware rejects requests without a valid token, while ability middleware can limit what that token may request.

Route::get('/orders', [OrderController::class, 'index'])
    ->middleware([
        'auth:sanctum',
        'abilities:orders:read',
    ]);

The abilities alias must be registered as described in Sanctum’s documentation. An ability limits the token’s intended operations; it does not replace the resource policy shown below.

After the login endpoint validates the credentials and identifies $user, the token can be issued with only the abilities the client needs. This example also sets an explicit expiration time:

$validated = $request->validate([
    'device_name' => ['required', 'string', 'max:100'],
]);

$token = $user->createToken(
    $validated['device_name'],
    ['orders:read'],
    now()->addDays(30),
);

return response()->json([
    'token' => $token->plainTextToken,
]);

The plain-text token is returned once. It should be kept in the mobile platform’s protected credential storage and excluded from logs. When the user logs out, the application can revoke the token that authenticated the request:

$request->user()->currentAccessToken()->delete();

Authentication identifies the user, but it does not decide whether that user may view a particular order. I enforce that decision on the server with a policy or gate:

use Illuminate\Support\Facades\Gate;

public function show(Order $order)
{
    Gate::authorize('view', $order);

    return new OrderResource($order);
}

Hiding an action in the mobile interface is not authorization because a caller can construct the request without using the interface. The server must check the resource on every protected operation.

Different Boundaries Need Different Controls

Sanctum was appropriate for the first-party API I was working with, but it is not the answer to every integration.

When a native application signs users in through an OAuth or OpenID Connect provider, the application is a public client and cannot safely keep a client secret. The authorization code flow with PKCE uses a verifier created for one authorization attempt. This limits the usefulness of an intercepted authorization code without pretending the app contains a private secret.

A server-to-server integration has a different boundary because both servers can protect credentials. Depending on the integration, that may allow rotatable client credentials, signed requests, mutual TLS, or OAuth client credentials. Signed requests also need replay protection, such as a timestamp and a unique request identifier.

Platform attestation is another optional signal. It can help a server assess whether a request came from a recognized app on a valid device, but it does not replace user authentication or resource authorization. I would add it only when the abuse risk justifies the extra platform-specific work.

Limit Abuse and Test the Boundary

Authentication does not prevent an authenticated account from sending too many requests. Laravel’s rate limiter can provide a basic limit:

Route::middleware(['auth:sanctum', 'throttle:api'])->group(function () {
    Route::get('/products', [ProductController::class, 'index']);
});

This snippet assumes the application has defined a named api limiter.

The useful limit depends on the operation. Login, password reset, search, and an expensive report have different cost and abuse profiles. Logs can record failed authentication, authorization failures, unusual request volume, and a correlation or token identifier, but they should not contain access tokens, credentials, or sensitive request bodies.

I also test attempts to cross the boundary, not only the successful request:

  • a request without a token;
  • an expired, revoked, or malformed token;
  • one user requesting another user’s resource;
  • a token attempting an ability it was not granted;
  • repeated sensitive requests reaching their intended limit;
  • sensitive fields remaining absent from responses and logs.

HTTPS and input validation still matter, but they solve different problems. HTTPS protects the request in transit, while validation determines whether its content is acceptable. Neither one establishes identity by itself.

What Changed in My Implementation

I began with a rule that tried to distinguish a mobile application from a browser. I ended with a smaller and clearer boundary: authenticate the user, authorize the requested resource on the server, and limit abuse separately.

That change did not make copied requests impossible. It made a copied X-App-Key insufficient to access another user’s data, which was the boundary I actually needed to protect.

References