Arquitetura Google Maps + Firebase
e Segurança para o Federal Pro
Guia completo de integração das APIs do Google Maps com o Firebase como orquestrador de estado, com segurança end-to-end: JWT, RBAC, proteção de PII (CPF, celular, localização), Firestore Rules, TLS pinning e rate limiting.
Visão Geral
O objetivo é ter um sistema de ride-hailing com matching inteligente, rotas com trânsito em tempo real e experiência fluida — com segurança de nível bancário para proteger motoristas e passageiros.
APIs Necessárias
| API | Para quê | Prioridade | Custo | Dados sensíveis? |
|---|---|---|---|---|
| Geocoding | Coordenadas → endereço | Essencial | 10K/mês | Localização |
| Places Autocomplete | Sugerir endereços | Essencial | 10K/mês | Query do user |
| Places Details | Coordenadas do local | Essencial | 10K/mês | Público |
| Directions | Rota + ETA | Essencial | 10K/mês | Trajeto |
| Distance Matrix | ETAs em lote | Opcional | $5/1K | Localizações |
| Roads | Snap à malha viária | Opcional | $10/1K | Histórico GPS |
Funções Detalhadas
Converte coordenadas GPS em endereço legível (reverse geocoding) ou endereço em coordenadas.
Chamada segura
Sempre via Cloud Function. O resultado é cacheado por 30 dias (permitido pelos TOS).
Devolve sugestões enquanto digita. Nunca chama diretamente do cliente — a query pode conter dados sensíveis.
Chamada segura
Proxy na Cloud Function + session token para agrupar billing.
Dado um place_id, devolve coordenadas e endereço formatado.
Chamada segura
Cache permanente no Firestore (place_id é imutável).
Calcula rota + distância + ETA + trânsito em tempo real.
Chamada segura
Apenas para o motorista designado. Cached 5 min para o mesmo par origem-destino.
Calcula ETA de múltiplos motoristas de uma vez.
Chamada segura
Só no backend, após filtragem por geohash. Nunca expõe lista completa de motoristas ao cliente.
"Snap" de coordenadas GPS à malha viária real.
Chamada segura
Só após viagem concluída. Dados guardados encriptados no Firestore.
Firebase como Orquestrador
Fonte única de verdade. Armazena localizações em tempo real, estado das viagens e resultados cacheados.
Backend serverless. Executa chamadas às APIs do Google, lógica de negócio e verificação de JWT.
Gera o JWT (ID Token) após login. Cada chamada a Cloud Function verifica este token.
Notificações push com payload assinado. Nunca inclui dados sensíveis.
Feature flags e configurações dinâmicas. Permite desativar funcionalidades em caso de incidente.
Métricas anonimizadas. Nunca envia PII (CPF, celular, localização precisa).
Arquitetura Híbrida
Fluxo Completo
- Login/Registo. O utilizador envia email+password ou telefone+SMS. Firebase Auth gera ID Token (JWT) + Refresh Token.
- Guardar tokens. ID Token (1h) em memória, Refresh Token em
expo-secure-store(Keychain/Keystore). - Cada chamada à API. O cliente envia
Authorization: Bearer <ID Token>. - Cloud Function verifica o JWT. Assinatura, expiração,
aud,iss, custom claims. - Verificação RBAC. O
roleno token define o que pode fazer (driver/passenger/admin). - Ação é executada. Ex: matching, cálculo de rota, escrita no Firestore.
- Resposta assinada. O cliente recebe dados filtrados apenas para aquele utilizador.
- Refresh automático. Se o ID Token expirar, o SDK usa o Refresh Token para obter um novo.
- Operações críticas. Pagamento, alteração de dados bancários exigem biometria local.
- Log e auditoria. Cada acesso a dados sensíveis gera um registo auditável.
🔒 Segurança de Dados do Utilizador
📊 Classificação de Dados
| Dado | Classificação | Onde guardar | Encriptação | Retenção |
|---|---|---|---|---|
| Nome completo | PII | Firestore (users/{uid}) | TLS + at-rest (Google) | Enquanto ativo + 5 anos |
| CPF | PII Sensível | Firestore encriptado AES-256 | Encriptação aplicacional | Enquanto ativo + 5 anos |
| Celular | PII | Firestore + Auth | TLS + at-rest | Enquanto ativo |
| PII | Firebase Auth | Hash + salt | Enquanto ativo | |
| Localização atual | PII Sensível | Firestore (TTL 90 dias) | TLS | 90 dias |
| Histórico de viagens | Interno | Firestore (trips/{id}) | TLS + at-rest | 2 anos |
| Dados bancários | PCI-DSS | Nunca no Firestore | Tokenizado (Stripe/Mercado Pago) | Nunca armazenar |
| Foto de perfil | PII | Cloud Storage (path privado) | Signed URLs (TTL 15min) | Enquanto ativo |
🛡️ Estratégias de Proteção
Recolhe apenas o estritamente necessário. Não peças CPF para agendar uma viagem. Não guardes localização exata depois de 90 dias.
Reduz superfície de ataque em ~60%
O CPF é encriptado antes de chegar ao Firestore, com chave gerida no Google Secret Manager. Nunca é guardado em plaintext.
Mostra apenas os últimos 2 dígitos do CPF, últimos 4 do celular. O utilizador confirma identidade, não lê o dado completo.
•••.•••.•••-42 | (11) 9••••-1234
Nunca guardar número de cartão. Usar tokens do Stripe/Mercado Pago/Pagar.me. PCI-DSS é responsabilidade do gateway.
Localização atual expira em 90 dias. Sessões em 30 dias. Logs em 1 ano. Firestore TTL policies automatizam isto.
Cada leitura de PII (CPF, celular, localização) é registada em audit_logs com uid, action, timestamp e ip.
📝 Exemplo: Encriptar CPF antes de guardar
import * as crypto from 'crypto'; import { SecretManagerServiceClient } from '@google-cloud/secret-manager'; const client = new SecretManagerServiceClient(); let cachedKey: Buffer | null = null; async function getKey(): Promise<Buffer> { if (cachedKey) return cachedKey; const [version] = await client.accessSecretVersion({ name: 'projects/federal-pro/secrets/pii-key/versions/latest', }); cachedKey = Buffer.from(version.payload.data as Uint8Array); return cachedKey; } /** Encripta PII com AES-256-GCM (autenticado) */ export async function encryptPII(plain: string): Promise<string> { const key = await getKey(); const iv = crypto.randomBytes(12); const cipher = crypto.createCipheriv('aes-256-gcm', key, iv); const encrypted = Buffer.concat([ cipher.update(plain, 'utf8'), cipher.final(), ]); const authTag = cipher.getAuthTag(); // Formato: iv.authTag.encrypted (base64) return [iv, authTag, encrypted].map((b) => b.toString('base64')).join('.'); } export async function decryptPII(payload: string): Promise<string> { const [ivB64, tagB64, dataB64] = payload.split('.'); const key = await getKey(); const decipher = crypto.createDecipheriv( 'aes-256-gcm', key, Buffer.from(ivB64, 'base64') ); decipher.setAuthTag(Buffer.from(tagB64, 'base64')); return Buffer.concat([ decipher.update(Buffer.from(dataB64, 'base64')), decipher.final(), ]).toString('utf8'); }
🎭 Mascarar PII na UI (cliente)
/** Mascara CPF mostrando apenas os últimos 2 dígitos */ export function maskCPF(cpf?: string): string { if (!cpf) return '—'; const d = String(cpf).replace(/\D/g, ''); if (d.length < 2) return '•••.•••.•••-••'; return `•••.•••.•••-${d.slice(-2)}`; } /** Mascara celular mostrando DDD + últimos 4 */ export function maskPhone(phone?: string): string { if (!phone) return '—'; const d = String(phone).replace(/\D/g, ''); if (d.length < 11) return '(••) •••••-••••'; return `(${d.slice(0, 2)}) ${d.slice(2, 3)}••••-${d.slice(-4)}`; } /** Mascara email */ export function maskEmail(email?: string): string { if (!email || !email.includes('@')) return '—'; const [user, domain] = email.split('@'); const visible = user.slice(0, 2); return `${visible}${'•'.repeat(Math.max(user.length - 2, 2))}@${domain}`; }
🔐 JWT & Autenticação
O JWT (JSON Web Token) é o mecanismo central de autenticação. Cada pedido a uma Cloud Function ou ao Firestore carrega um token que prova a identidade do utilizador. O backend nunca confia no cliente — verifica sempre a assinatura e as permissões.
🎫 Anatomia do JWT
// Header { "alg": "RS256", "typ": "JWT", "kid": "firebase-key-1" } // Payload (claims) { "iss": "https://securetoken.google.com/federal-pro", "aud": "federal-pro", "auth_time": 1790000000, "user_id": "abc123uid", "sub": "abc123uid", "iat": 1790000000, "exp": 1790003600, // 1 hora "email": "motorista@exemplo.com", "email_verified": true, "phone_number": "+5511999998888", "role": "driver", // custom claim "categoria": "pro", // custom claim "status": "aprovado" // custom claim } // Signature (RS256) — verifica com chave pública do Firebase
🔄 Ciclo de Vida dos Tokens
| Token | Duração | Onde guardar | Para quê |
|---|---|---|---|
| ID Token (JWT) | 1 hora | Memória (nunca em disco) | Autorizar pedidos às APIs |
| Refresh Token | 30 dias | SecureStore (Keychain/Keystore) | Obter novo ID Token quando expira |
| Session Token (Places) | Até fechar sessão de autocomplete | Memória | Agrupar billing de autocomplete |
| Custom Claims | Refrescados ao pedir novo ID Token | Dentro do ID Token | RBAC (role, categoria, status) |
📝 Implementação: Login com JWT
import * as SecureStore from 'expo-secure-store'; import auth from '@react-native-firebase/auth'; const REFRESH_KEY = 'fp_refresh_token'; // Guardar o refresh token em armazenamento seguro (Keychain/Keystore) export async function persistRefreshToken(token: string) { await SecureStore.setItemAsync(REFRESH_KEY, token, { keychainAccessible: SecureStore.WHEN_UNLOCKED_THIS_DEVICE_ONLY, }); } export async function getRefreshToken() { return SecureStore.getItemAsync(REFRESH_KEY); } export async function clearTokens() { await SecureStore.deleteItemAsync(REFRESH_KEY); } /** Devolve um ID Token válido (renova automaticamente se expirado) */ export async function getValidIdToken(forceRefresh = false): Promise<string | null> { const user = auth().currentUser; if (!user) return null; return user.getIdToken(forceRefresh); } /** Wrapper que injeta o JWT em todos os pedidos HTTP */ export async function secureFetch(url: string, options: RequestInit = {}) { const token = await getValidIdToken(); const res = await fetch(url, { ...options, headers: { ...options.headers, 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json', }, }); // Se 401, força refresh do token e tenta 1x if (res.status === 401) { const fresh = await getValidIdToken(true); return fetch(url, { ...options, headers: { ...options.headers, 'Authorization': `Bearer ${fresh}` }, }); } return res; }
🛡️ Verificar JWT no Backend
import * as admin from 'firebase-admin'; import * as functions from 'firebase-functions'; /** Wrapper que exige JWT válido em qualquer Cloud Function */ export function withAuth( handler: (data: any, ctx: AuthContext) => Promise<any>, requiredRole?: 'driver' | 'passenger' | 'admin' ) { return functions.https.onCall(async (data, context) => { // 1. Verifica se há token if (!context.auth) { throw new functions.https.HttpsError( 'unauthenticated', 'JWT ausente ou inválido' ); } const { uid, token } = context.auth; // 2. Verifica se o token não foi revogado const revoked = await admin.auth().verifyIdToken(context.auth.token.toString(), true); // 3. Verifica role (RBAC) if (requiredRole && token.role !== requiredRole) { throw new functions.https.HttpsError( 'permission-denied', `Requer role '${requiredRole}', tem '${token.role}'` ); } // 4. Verifica se a conta está aprovada if (token.status !== 'aprovado') { throw new functions.https.HttpsError( 'permission-denied', 'Conta pendente de aprovação' ); } // 5. Rate limiting (por uid) const allowed = await checkRateLimit(uid, 60, '1m'); if (!allowed) { throw new functions.https.HttpsError( 'resource-exhausted', 'Demasiados pedidos' ); } // 6. Auditoria await admin.firestore().collection('audit_logs').add({ uid, action: 'call', function: handler.name, timestamp: admin.firestore.FieldValue.serverTimestamp(), }); return handler(data, { uid, role: token.role, claims: token }); }); } type AuthContext = { uid: string; role: 'driver' | 'passenger' | 'admin'; claims: any; };
🔑 Custom Claims — Definir Role
import * as admin from 'firebase-admin'; import * as functions from 'firebase-functions'; /** Quando um utilizador é criado, define custom claims */ export const onUserCreate = functions.auth.user().onCreate( async (user) => { // Todos começam como passageiro até aprovação await admin.auth().setCustomUserClaims(user.uid, { role: 'passenger', categoria: 'light', status: 'pendente', }); // Cria documento no Firestore com dados públicos await admin.firestore().collection('users').doc(user.uid).set({ email: user.email ?? null, phone: user.phoneNumber ?? null, role: 'passenger', status: 'pendente', createdAt: admin.firestore.FieldValue.serverTimestamp(), }); } ); /** Promover para motorista (chamado pelo admin) */ export const promoteToDriver = functions.https.onCall( async (data, context) => { if (context.auth?.token.role !== 'admin') { throw new functions.https.HttpsError('permission-denied', 'Só admin'); } const { uid, categoria } = data; await admin.auth().setCustomUserClaims(uid, { role: 'driver', categoria, status: 'aprovado', }); // Força refresh do token no cliente await admin.auth().revokeRefreshTokens(uid); return { ok: true }; } );
🌐 Segurança do Tráfego
Todo o tráfego cliente ↔ backend usa TLS 1.3. Cloud Functions e Firestore já o fazem por defeito. Proíbe HTTP no domínio.
No app, fixa o certificado SSL do teu backend para impedir ataques man-in-the-middle com certificados falsos.
Limite de pedidos por uid, IP e endpoint. Ex: 60/min para APIs normais, 5/min para operações sensíveis (login, pagamento).
Cloud Functions apenas aceitam pedidos dos domínios autorizados. Bloqueia * em produção.
Firebase App Check valida que os pedidos vêm do teu app genuíno (Play Integrity / DeviceCheck). Bloqueia bots e scripts.
Padrões suspeitos (login de IP diferente, muitas viagens em 1min) disparam 2FA ou bloqueio temporário.
Cloud Armor no Google Cloud filtra SQL injection, XSS, DDoS. Regras pré-configuradas pela Google.
Notificações push e webhooks críticos têm assinatura HMAC-SHA256. O cliente verifica antes de agir.
🔒 Certificate Pinning no App
{
"expo": {
"plugins": [
["expo-build-properties", {
"android": {
"networkSecurityConfig": "./network_security_config.xml"
},
"ios": {
"useFrameworks": "static"
}
}]
]
}
}
<?xml version="1.0" encoding="utf-8"?> <network-security-config> <domain-config cleartextTrafficPermitted="false"> <domain includeSubdomains="true">federalpro.com.br</domain> <domain includeSubdomains="true">cloudfunctions.net</domain> <pin-set expiration="2026-12-31"> <!-- SHA-256 do certificado (extrair com openssl) --> <pin digest="SHA-256">AAAA...base64...=</pin> <!-- Backup pin (rotação) --> <pin digest="SHA-256">BBBB...base64...=</pin> </pin-set> </domain-config> <!-- Bloquear tudo o resto em cleartext --> <base-config cleartextTrafficPermitted="false"> <trust-anchors> <certificates src="system" /> </trust-anchors> </base-config> </network-security-config>
⏱️ Rate Limiting no Backend
import * as admin from 'firebase-admin'; /** Sliding window rate limit usando Firestore + TTL */ export async function checkRateLimit( identifier: string, maxRequests: number, window: '1m' | '1h' | '1d' = '1m' ): Promise<boolean> { const windowMs = { '1m': 60_000, '1h': 3_600_000, '1d': 86_400_000 }[window]; const now = Date.now(); const bucket = Math.floor(now / windowMs); const docRef = admin.firestore() .collection('rate_limits') .doc(`${identifier}_${bucket}`); const allowed = await admin.firestore().runTransaction(async (tx) => { const doc = await tx.get(docRef); const count = (doc.data()?.count ?? 0) + 1; if (count > maxRequests) return false; tx.set(docRef, { count, expiresAt: admin.firestore.Timestamp.fromMillis(now + windowMs), }, { merge: true }); return true; }); return allowed; } // Configurar TTL no Firestore Console: // Collection: rate_limits | Field: expiresAt
🛂 RBAC & Firestore Rules
👥 Roles e Permissões
| Role | Pode ler | Pode escrever | Endpoints autorizados |
|---|---|---|---|
| passenger | O seu próprio perfil, viagens onde é participante | O seu perfil, criar pedidos de viagem | createTripRequest, getRoute |
| driver | O seu perfil, viagens atribuídas, dados mínimos do passageiro | O seu perfil, localização, aceitar/rejeitar viagens | matchDriver, acceptTrip, updatePosition |
| admin | Tudo, dentro do âmbito operacional | Promover utilizadores, alterar tarifas, bloquear contas | promoteToDriver, blockUser, setTariffs |
🔥 Firestore Security Rules
rules_version = '2'; service cloud.firestore { match /databases/{database}/documents { // ---------- Helpers ---------- function isSignedIn() { return request.auth != null; } function isOwner(uid) { return isSignedIn() && request.auth.uid == uid; } function isRole(role) { return isSignedIn() && request.auth.token.role == role; } function isApproved() { return isSignedIn() && request.auth.token.status == 'aprovado'; } function isParticipant() { return isSignedIn() && request.auth.uid in resource.data.participants; } // ---------- Users ---------- match /users/{uid} { // Só o próprio utilizador lê o seu perfil completo allow read: if isOwner(uid) || isRole('admin'); // Só o próprio pode atualizar o seu perfil, e não pode alterar role/status allow update: if isOwner(uid) && !request.resource.data.diff(resource.data).affectedKeys() .hasAny(['role', 'status', 'cpf_encrypted']); // Ninguém apaga o próprio perfil (só admin) allow delete: if isRole('admin'); } // ---------- Drivers (localização em tempo real) ---------- match /drivers/{driverId} { // Motorista escreve apenas no seu próprio documento allow write: if isOwner(driverId) && isRole('driver') && isApproved(); // Qualquer utilizador autenticado pode ler (necessário para matching) // MAS apenas campos públicos (não inclui cpf, email, etc.) allow read: if isSignedIn(); } // ---------- Trips ---------- match /trips/{tripId} { allow read: if isParticipant() || isRole('admin'); allow create: if isSignedIn() && request.resource.data.participants.hasAll([request.auth.uid]); allow update: if isParticipant(); } // ---------- Match requests (efémeros) ---------- match /match_requests/{uid} { allow read: if isOwner(uid); // Escrita só via Cloud Function (Admin SDK) allow write: if false; } // ---------- Cache (geocoding, places) ---------- match /cache/{type}/{hash} { allow read: if isSignedIn(); // Nunca escrever diretamente — só Cloud Functions allow write: if false; } // ---------- Audit logs ---------- match /audit_logs/{logId} { allow read: if isRole('admin'); allow write: if false; // apenas Admin SDK } // ---------- Tudo o resto: negar por defeito ---------- match /{document=**} { allow read, write: if false; } } }
🗄️ Storage Rules (fotos, documentos)
rules_version = '2'; service firebase.storage { match /b/{bucket}/o { // Fotos de perfil — só o próprio lê/escreve match /avatars/{uid}/{fileName} { allow read: if request.auth.uid == uid; allow write: if request.auth.uid == uid && request.resource.size < 5 * 1024 * 1024 // 5MB && request.resource.contentType.matches('image/.*'); } // Documentos (CNH, CRLV) — só o próprio e admins match /documents/{uid}/{docId} { allow read: if request.auth.uid == uid || request.auth.token.role == 'admin'; allow write: if request.auth.uid == uid && request.resource.size < 10 * 1024 * 1024; } // Nada mais é acessível match /{allPaths=**} { allow read, write: if false; } } }
Otimização de Custos
Os TOS permitem cachear até 30 dias. Guarda no Firestore com hash das coordenadas arredondadas.
Poupança: ~70%
Cacheia -22.90841 em vez de -22.9084125.
Poupança: ~50% das chaves
Filtra por geohash primeiro. Envia só 10-20 candidatos.
Poupança: ~95%
Só chama Places após 300ms sem digitar.
Poupança: ~80%
Define limite mensal no Cloud Console. Desativa APIs se ultrapassar.
Cada API ativa é superfície de ataque + linha na fatura.
Otimização de Desempenho
Firestore onSnapshot() em vez de polling. Latência ~100ms.
Renderiza só pontos no viewport + simplifica com Douglas-Peucker.
southamerica-east1. Latência de 200ms → 30ms.
Promise.all() ao fazer login. Não esperes em série.
Otimização de Recursos
- GPS accuracy: Balanced
- Intervalo 5-10s
- Pausar quando offline
- Unsubscribe listeners
- FlatList > ScrollView
- useMemo em cálculos
- Offline-first com fila
- gzip nas Functions
- Batch coordenadas
Experiência do Utilizador
Micro-scale, foco animado, skeleton em vez de spinner.
Mostra posição aproximada imediatamente. Endereço depois.
FaceID/impressão digital para pagamentos, mudança de senha, apagar conta.
"Sem internet" em vez de "Network request failed".
Vibração leve em ações importantes.
Motorista trabalha à noite. Reduz fadiga e poupa bateria.
Código Prático
import * as functions from 'firebase-functions'; import * as admin from 'firebase-admin'; import { getGeohashRange } from 'geofire-common'; import axios from 'axios'; import { withAuth } from './middleware'; admin.initializeApp(); const db = admin.firestore(); const GOOGLE_KEY = functions.config().google.key; const RAIO_KM = 5; // withAuth exige JWT válido + role passenger + rate limit export const matchDriver = withAuth(async (data, ctx) => { const { lat, lng } = data; // Validar input (defesa contra injection) if (typeof lat !== 'number' || typeof lng !== 'number') { throw new functions.https.HttpsError('invalid-argument', 'Coords inválidas'); } const range = getGeohashRange(lat, lng, RAIO_KM * 1000); const snapshot = await db.collection('drivers') .where('status', '==', 'online') .where('geohash', '>=', range.lower) .where('geohash', '<=', range.upper) .limit(20) .get(); if (snapshot.empty) { return { drivers: [], message: 'Nenhum motorista próximo' }; } const drivers = snapshot.docs.map((d) => { const data = d.data(); // Retorna apenas campos públicos — nunca cpf, email, etc. return { id: d.id, lat: data.lat, lng: data.lng, name: data.name, rating: data.rating, vehicle: data.vehicle, fcmToken: data.fcmToken, }; }); const origins = drivers.map((d) => `${d.lat},${d.lng}`).join('|'); const destination = `${lat},${lng}`; const { data: matrix } = await axios.get( 'https://maps.googleapis.com/maps/api/distancematrix/json', { params: { origins, destinations: destination, departure_time: 'now', traffic_model: 'best_guess', key: GOOGLE_KEY } } ); const elements = matrix.rows[0].elements; const candidates = drivers .map((driver, i) => ({ driver, distance: elements[i].distance.value, durationInTraffic: elements[i].duration_in_traffic?.value ?? elements[i].duration.value, })) .filter((c) => c.durationInTraffic > 0) .sort((a, b) => a.durationInTraffic - b.durationInTraffic); const best = candidates[0]; // Grava match com TTL de 30s await db.collection('match_requests').doc(ctx.uid).set({ driverId: best.driver.id, distanceMeters: best.distance, etaSeconds: best.durationInTraffic, status: 'pending', createdAt: admin.firestore.FieldValue.serverTimestamp(), expiresAt: new Date(Date.now() + 30000), }); // Push ao motorista (sem PII no payload) await admin.messaging().send({ token: best.driver.fcmToken, notification: { title: 'Nova viagem!', body: `${(best.distance / 1000).toFixed(1)} km • ${Math.ceil(best.durationInTraffic / 60)} min`, }, data: { matchId: ctx.uid, type: 'new_trip' }, }); // Devolve ao cliente apenas o necessário return { matchId: ctx.uid, driver: { name: best.driver.name, rating: best.driver.rating, vehicle: best.driver.vehicle, }, distance: best.distance, eta: best.durationInTraffic, }; }, 'passenger');
Resumo Geral
| Componente | Responsabilidade | Segurança | Onde |
|---|---|---|---|
| Firebase Auth | Login + JWT | ID Token RS256 | Backend |
| expo-secure-store | Guardar refresh token | Keychain/Keystore | Cliente |
| Firestore | Estado + cache | Rules + AES-256 para PII | Backend |
| Cloud Functions | Orquestração | withAuth + RBAC | Backend |
| Geocoding API | Coords → endereço | Proxy + cache | Cloud Function |
| Places API | Autocomplete | Session token | Cloud Function |
| Directions API | Rotas | Só para participante | Cloud Function |
| Distance Matrix | ETAs em lote | Pós-GeoFire | Cloud Function |
| App Check | Anti-bot | Play Integrity | Backend |
| Certificate Pinning | Anti-MITM | TLS pinning | Cliente |
| Rate Limiting | Anti-abuso | Sliding window | Cloud Function |
| Audit Logs | Rastreabilidade | Imutáveis | Backend |