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_ids. It does not need to appear in the token's aud value.

In typical mobile sign-in flows aud is a single string (the app's bundle ID or OAuth client ID), while azp may identify another trusted client in the same app family.

Security Notes