MobileIdToken

MobileIdToken logo

MobileIdToken verifies Apple and Google OAuth id_token JWTs in mobile-first backend flows.

It validates JWT signature (RS256), issuer, audience, expiry, subject, nonce, and provider email-verification claims, with in-memory JWKS caching for key rotation.

Usage

Use MobileIdToken.verify/3 as the primary API.

Google example

case MobileIdToken.verify(:google, id_token,
       client_ids: ["your-google-client-id"],
       nonce: "optional-nonce"
     ) do
  {:ok, claims} ->
    claims

  {:error, reason} ->
    {:error, reason}
end

Apple example

case MobileIdToken.verify(:apple, id_token,
       client_ids: ["com.example.ios"],
       nonce: "expected-nonce"
     ) do
  {:ok, claims} ->
    claims

  {:error, reason} ->
    {:error, reason}
end

Real controller-style example

def apple(conn, %{"id_token" => id_token, "nonce" => nonce}) do
  case MobileIdToken.verify(:apple, id_token,
         client_ids: Application.get_env(:my_app, :apple_oauth_client_ids, []),
         nonce: nonce
       ) do
    {:ok, claims} ->
      json(conn, %{data: claims})

    {:error, :invalid_signature} ->
      send_resp(conn, 401, "Invalid token signature")

    {:error, :invalid_audience} ->
      send_resp(conn, 401, "Invalid client id")

    {:error, :token_expired} ->
      send_resp(conn, 401, "Token expired")

    {:error, :invalid_nonce} ->
      send_resp(conn, 401, "Invalid login nonce")

    {:error, :jwks_unavailable} ->
      send_resp(conn, 503, "Unable to verify token right now")

    {:error, reason} ->
      send_resp(conn, 422, Atom.to_string(reason))
  end
end

Why This Exists

Most Elixir OAuth libraries focus on server-side OAuth flows where your backend performs redirect/callback handling.

This package focuses on one problem:

What It Does

What It Does Not Do

This is a verification primitive you compose inside your own auth pipeline.

Installation

def deps do
  [
    {:mobile_id_token, "~> 0.1.0"}
  ]
end

API

Return shape:

Options

Audience validation behavior:

Provider-specific nonce behavior:

Client ID Configuration

The library intentionally does not read app environment variables directly.

Host apps must resolve config and pass :client_ids explicitly.

Example host convention (optional):

# config/runtime.exs
config :my_app, :google_oauth_client_ids,
  System.get_env("GOOGLE_OAUTH_CLIENT_IDS", "")
  |> String.split(",", trim: true)
  |> Enum.reject(&(&1 == ""))

config :my_app, :apple_oauth_client_ids,
  System.get_env("APPLE_OAUTH_CLIENT_IDS", "")
  |> String.split(",", trim: true)
  |> Enum.reject(&(&1 == ""))

Then pass them into verify/3 as shown in the controller example.

Nonce Matching Details

Nonce matching accepts either:

This supports providers/SDKs that send hashed nonce claims (commonly seen in Apple flows).

JWKS Caching

JWKS are cached in :persistent_term for 600 seconds.

Verification flow:

  1. read cached JWKS
  2. select JWK by kid
  3. if missing, force refresh once and retry
  4. fail with :jwk_not_found or :jwks_unavailable

Current behavior note:

Error Atoms

Provider Notes

Google:

Apple:

Multi-Audience Tokens

If a token's aud claim is an array, every value must be in :client_ids. This matches the OIDC Core §3.1.3.7 requirement to reject tokens that list audiences the client does not trust — not just tokens that omit the client's own ID.

When the azp (authorized party) claim is present, it must also be in :client_idsand appear in the aud array.

In typical mobile sign-in flows aud is a single string (the app's bundle ID or OAuth client ID), so this rarely matters in practice. The strict behavior is a defense-in-depth measure against cross-application token replay in less common multi-audience flows.

Security Notes