Firebase auth (ID token)¶
Your mobile app signs in with Firebase, gets an ID token and sends that token to your API. The backend issued nothing — it only has to prove the token is genuine before resolving who the caller is.
FirebaseAuth packages that whole path:
- it initializes the
firebase_adminapp exactly once, even when you build the authenticator in two different modules; - it verifies the ID token off the event loop (Google's verifier is synchronous);
- it hands you a typed identity (
FirebaseIdentity), not adict[str, Any]; and - it translates every Firebase failure into the SDK's exception
hierarchy, with a distinct
codefor each one.
Installation
Needs the [firebase] extra — uv add "tempest-fastapi-sdk[firebase]".
It is heavy: firebase-admin pulls grpcio, protobuf,
google-api-core, google-auth and the Firestore/Storage clients.
Measured with firebase-admin 7.5.0 in a clean venv: 33 packages,
52 MB. That is why it stays out of the [all] extra, and why
the import is lazy — import tempest_fastapi_sdk keeps working
without it installed.
When to use this
Use it when the client already arrives with a Firebase ID token (a Flutter / React Native app, or web using the Firebase JS SDK).
- If your service is the one logging users in and issuing tokens, you want Auth flow.
- If you receive an opaque bearer and validate it by asking an
upstream
userinfoendpoint, you want Introspection auth.
The minimal path¶
Build it once, in the dependencies layer:
# src/api/dependencies/auth.py
from tempest_fastapi_sdk import FirebaseAuth
from src.core.settings import settings
firebase = FirebaseAuth(
credentials_path=settings.FIREBASE_CREDENTIALS_PATH,
project_id=settings.FIREBASE_PROJECT_ID,
)
Then use the methods directly as dependencies:
# src/api/routers/profile.py
from fastapi import APIRouter, Depends
from tempest_fastapi_sdk import FirebaseAuth, FirebaseIdentity
from src.core.settings import settings
firebase = FirebaseAuth(credentials_path=settings.FIREBASE_CREDENTIALS_PATH)
router = APIRouter(prefix="/api/profile", tags=["profile"])
@router.get("/me")
async def me(
identity: FirebaseIdentity = Depends(firebase.get_identity),
) -> dict[str, str]:
"""Return the caller authenticated by the Firebase ID token."""
return {"uid": identity.uid, "email": identity.email or ""}
@router.get("/uid")
async def uid(user_id: str = Depends(firebase.get_uid)) -> dict[str, str]:
"""When you only need the id, not the whole identity."""
return {"uid": user_id}
That is it. A request without Authorization gets 401; an expired,
tampered or wrong-project token gets 401 — each with its own code.
Register the exception handlers
FirebaseAuth raises subclasses of the SDK's own
UnauthorizedException and ForbiddenException. Call
register_exception_handlers(app) (the SDK's create_app() already
does) so they become 401/403 with a
{"detail": ..., "code": ...} body instead of a 500.
How it works, piece by piece¶
Idempotent initialization¶
firebase_admin.initialize_app() raises ValueError on the second
call. That is why every service ends up with the same
get_app() / except ValueError block copied across three files.
FirebaseAuth owns that block. Building it twice with the same
app_name reuses the existing app:
from tempest_fastapi_sdk import FirebaseAuth
from src.core.settings import settings
first = FirebaseAuth(credentials_path=settings.FIREBASE_CREDENTIALS_PATH)
second = FirebaseAuth(credentials_path=settings.FIREBASE_CREDENTIALS_PATH)
This is not theory: tests/auth/test_firebase.py builds two
authenticators and asserts both point at the same app.
Need to talk to two Firebase projects in one process? Give them different names:
from tempest_fastapi_sdk import FirebaseAuth
from src.core.settings import settings
consumers = FirebaseAuth(
credentials_path=settings.FIREBASE_CREDENTIALS_PATH,
app_name="consumers",
)
drivers = FirebaseAuth(
credentials_path=settings.FIREBASE_DRIVERS_CREDENTIALS_PATH,
app_name="drivers",
)
Where the credential comes from¶
Three channels, in this order of precedence:
credentials_json— the service-account JSON inline, for deployments that inject secrets as environment variables and mount no volume;credentials_path— the service-account file on disk;- nothing — falls back to the environment's default credential
(
GOOGLE_APPLICATION_CREDENTIALS, or the metadata server when you run inside Google's infrastructure).
import os
from tempest_fastapi_sdk import FirebaseAuth
firebase = FirebaseAuth(
credentials_json=os.environ["FIREBASE_CREDENTIALS_JSON"],
project_id="my-app-3f21c",
)
Invalid JSON, a missing file, or an environment with no default
credential all become FirebaseCredentialError — a RuntimeError,
not an AppException. The reason: this is a configuration failure,
it happens at construction, never in the middle of a request.
The typed identity¶
FirebaseIdentity is a frozen dataclass. The handler never sees the raw
dict:
import asyncio
from tempest_fastapi_sdk import FirebaseAuth, FirebaseIdentity
from src.core.settings import settings
firebase = FirebaseAuth(credentials_path=settings.FIREBASE_CREDENTIALS_PATH)
async def main() -> None:
"""Run this example."""
identity: FirebaseIdentity = await firebase.verify("eyJhbGciOi...")
print(identity.uid)
print(identity.email, identity.email_verified)
print(identity.phone_number)
print(identity.provider) # "google.com", "password", "phone", ...
print(identity.claims["role"]) # custom claims stay reachable
asyncio.run(main())
claims keeps everything the token carried, including custom claims
you set with set_custom_user_claims. The named fields are what 99% of
routes use; claims is the escape hatch for the rest.
The errors, one code per failure¶
| Situation | Exception | HTTP | code |
|---|---|---|---|
No Authorization header |
FirebaseTokenMissingError |
401 | FIREBASE_TOKEN_MISSING |
| Malformed token, bad signature, other project | FirebaseTokenInvalidError |
401 | FIREBASE_TOKEN_INVALID |
| Expired token | FirebaseTokenExpiredError |
401 | FIREBASE_TOKEN_EXPIRED |
Revoked token (only with check_revoked=True) |
FirebaseTokenRevokedError |
401 | FIREBASE_TOKEN_REVOKED |
Disabled user (only with check_revoked=True) |
FirebaseUserDisabledError |
403 | FIREBASE_USER_DISABLED |
| Google certificates unreachable | FirebaseUnavailableError |
401 | FIREBASE_UNAVAILABLE |
Why the except ordering matters
On firebase-admin 7.5.0, ExpiredIdTokenError and
RevokedIdTokenError are subclasses of InvalidIdTokenError
(measured, not deduced). An implementation catching the parent first
would collapse all three cases into FIREBASE_TOKEN_INVALID — and
the client would lose the very information that decides between
"refresh the token" and "sign in again". The SDK's parametrized test
pins that ordering.
A disabled user is 403, not 401
They proved who they are; what is missing is permission. That is why it is the only failure the soft variant below still raises.
The soft variant — a route serving anonymous and signed-in callers¶
get_optional_identity returns None instead of raising:
from fastapi import APIRouter, Depends
from tempest_fastapi_sdk import FirebaseAuth, FirebaseIdentity
from src.core.settings import settings
firebase = FirebaseAuth(credentials_path=settings.FIREBASE_CREDENTIALS_PATH)
router = APIRouter(prefix="/api/feed", tags=["feed"])
@router.get("/")
async def feed(
identity: FirebaseIdentity | None = Depends(firebase.get_optional_identity),
) -> dict[str, bool]:
"""Personalize when a token is present, serve anonymously otherwise."""
return {"personalized": identity is not None}
No header, None. A token that fails verification, also None (logged
at DEBUG with the code, never with the token). A disabled user
still gets 403.
Need to narrow the type inside the handler? Use the SDK's guard:
from fastapi import APIRouter, Depends
from tempest_fastapi_sdk import FirebaseAuth, FirebaseIdentity
from tempest_fastapi_sdk.auth import require_authenticated
from src.core.settings import settings
firebase = FirebaseAuth(credentials_path=settings.FIREBASE_CREDENTIALS_PATH)
router = APIRouter(prefix="/api/orders", tags=["orders"])
@router.post("/")
async def create_order(
maybe: FirebaseIdentity | None = Depends(firebase.get_optional_identity),
) -> dict[str, str]:
"""Accept the request only when a token arrived and verified."""
identity: FirebaseIdentity = require_authenticated(maybe)
return {"uid": identity.uid}
From the uid to your user¶
The SDK does not decide how a Firebase uid becomes a user in your
database — that is your rule (a column lookup, just-in-time
provisioning, a call to another service). FirebaseUserResolver is the
seam:
from fastapi import APIRouter, Depends
from tempest_fastapi_sdk import (
FirebaseAuth,
FirebaseIdentity,
FirebaseUserResolver,
)
from src.core.settings import settings
from src.db.models import UserModel
from src.db.repositories import UserRepository
firebase = FirebaseAuth(credentials_path=settings.FIREBASE_CREDENTIALS_PATH)
repository = UserRepository()
async def load_user(identity: FirebaseIdentity) -> UserModel | None:
"""Map the verified identity onto the local user."""
return await repository.get_by_firebase_uid(identity.uid)
users: FirebaseUserResolver[UserModel] = FirebaseUserResolver(firebase, load_user)
router = APIRouter(prefix="/api/account", tags=["account"])
@router.get("/")
async def account(user: UserModel = Depends(users.get_user)) -> dict[str, str]:
"""The route already receives the database user, concretely typed."""
return {"id": str(user.id)}
A resolver answering None means "this identity has no user here" — it
becomes a 401 with FIREBASE_TOKEN_INVALID and details={"uid": ...},
not an empty response. get_optional_user is the soft version.
repository and UserModel are your application's glue
UserRepository.get_by_firebase_uid(...) stands for your data
layer — it is not part of the SDK. Swap it for your project's real
call.
check_revoked — the price of knowing right away¶
By default verification is local: signature plus claims against
Google's public certificates, which firebase_admin fetches and caches.
In that mode, revoking a session in the console does not kill the token
until it expires.
check_revoked=True makes every verification also ask the Firebase
backend whether the token was revoked and whether the user is disabled —
one network round-trip per request:
from tempest_fastapi_sdk import FirebaseAuth
from src.core.settings import settings
firebase = FirebaseAuth(
credentials_path=settings.FIREBASE_CREDENTIALS_PATH,
check_revoked=True,
clock_skew_seconds=5,
)
clock_skew_seconds gives slack to a client clock running fast — handy
when the app runs on a phone with a manually set time.
Configuration through settings¶
The FirebaseSettings mixin already carries the three variables, with
title and description in the same shape as the others:
from tempest_fastapi_sdk import BaseAppSettings, FirebaseAuth
from tempest_fastapi_sdk.settings import FirebaseSettings
class Settings(FirebaseSettings, BaseAppSettings):
"""Application settings."""
settings = Settings()
firebase = FirebaseAuth(**settings.firebase_kwargs())
| Variable | What for |
|---|---|
FIREBASE_PROJECT_ID |
The project the tokens belong to. Optional when the service account already carries it. |
FIREBASE_CREDENTIALS_PATH |
Path to the service-account file. |
FIREBASE_CREDENTIALS_JSON |
The same JSON inline, for deployments with no mounted volume. |
firebase_kwargs() drops empty values, so an unset variable leaves
the constructor default in place instead of passing an empty path.
settings.enabled tells whether an explicit service account is
configured — note that False does not prevent verification: the
environment's default credential still works.
Testing¶
Patch verify_id_token on the real module — that way the error mapping
is exercised against the genuine exception classes, with their genuine
inheritance:
from typing import Any
import pytest
from firebase_admin import auth as firebase_auth
from tempest_fastapi_sdk import FirebaseAuth, FirebaseTokenExpiredError
CLAIMS: dict[str, Any] = {"uid": "uid-123", "email": "person@example.com"}
async def test_expired_token(monkeypatch: pytest.MonkeyPatch) -> None:
"""An expired token becomes the SDK code, not Google's exception."""
def fake_verify(id_token: str, **_: Any) -> dict[str, Any]:
"""Stand in for Google's verifier."""
raise firebase_auth.ExpiredIdTokenError("expired", None)
monkeypatch.setattr(firebase_auth, "verify_id_token", fake_verify)
firebase = FirebaseAuth(credentials_path="tests/fixtures/service-account.json")
with pytest.raises(FirebaseTokenExpiredError) as error:
await firebase.verify("token")
assert error.value.code == "FIREBASE_TOKEN_EXPIRED"
No network, no real credential
The SDK's suite generates a local RSA key and assembles a
syntactically valid service-account file — initialize_app accepts
it, because it never talks to Google. And a token that is not a JWT
is rejected without touching the network (measured): the
verifier checks the structure before fetching any certificate.
Recap¶
FirebaseAuthverifies Firebase ID tokens; Firebase issues them, your service does not.- Initialization is idempotent per
app_name— building twice reuses the same app; distinct names talk to distinct projects. - The credential comes from inline JSON, a file, or the environment's
default credential, in that order. Configuration failures raise
FirebaseCredentialError. get_identity/get_uidare strict;get_optional_identityis the soft variant returningNone. A disabled user is 403 in both.- Every failure has its own
code; theexceptordering preserves the difference between expired, revoked and invalid. FirebaseUserResolver[UserT]links the identity to your database user without the SDK deciding the rule.- The
[firebase]extra is heavy (33 packages, 52 MB measured) and stays out of[all]; the import is lazy, so only instantiating needs it.