Por que Autenticação com Rotas Protegidas Mudou em 2026
Vou ser sincero: implementar autenticação em React Native costumava ser uma experiência frustrante. Verificações de sessão espalhadas por vários arquivos, redirects manuais que deep links conseguiam burlar, e aquele boilerplate repetitivo que só crescia junto com o app. Quem nunca passou por isso?
Com o Expo SDK 55 e o Expo Router v7, tudo isso mudou. A API Stack.Protected trouxe uma forma declarativa, limpa e (finalmente) à prova de falhas pra proteger rotas — sem hacks de navegação, sem race conditions.
Neste guia, vamos implementar do zero um sistema de autenticação completo usando Stack.Protected, incluindo login persistente, controle de acesso baseado em roles e proteção em tabs. Tudo com exemplos de código funcionais e prontos pra copiar.
O que é Stack.Protected e Como Funciona
O Stack.Protected é um componente declarativo do Expo Router que impede usuários de acessar determinadas rotas via navegação no lado do cliente. Na prática, ele funciona com uma prop guard do tipo booleano:
- guard={true} — as telas dentro do bloco ficam acessíveis normalmente
- guard={false} — as telas ficam bloqueadas e o usuário é redirecionado automaticamente pra primeira tela disponível (a chamada anchor route)
O mais interessante é o comportamento reativo. Se o guard muda pra false enquanto o usuário está numa tela protegida, ele é redirecionado instantaneamente. E tem mais: todo o histórico de navegação daquelas telas protegidas é removido. Sem chance de voltar com o botão "back".
Disponibilidade
O Stack.Protected está disponível a partir do Expo SDK 53 e funciona com Stack, Tabs, Drawer e qualquer navegador customizado criado com withLayoutContext.
Estrutura de Pastas do Projeto
Antes de meter a mão no código, vamos definir a estrutura de pastas. O Expo SDK 55 adota o padrão /src/app por padrão:
src/
├── app/
│ ├── _layout.tsx # Layout raiz (SessionProvider + guards)
│ ├── sign-in.tsx # Tela de login (pública)
│ └── (app)/
│ ├── _layout.tsx # Layout das telas autenticadas
│ ├── index.tsx # Tela principal (home)
│ ├── profile.tsx # Perfil do usuário
│ └── (admin)/
│ ├── _layout.tsx # Layout do painel admin
│ └── dashboard.tsx # Dashboard admin (role-based)
├── context/
│ └── AuthContext.tsx # Provider de autenticação
├── hooks/
│ └── useStorageState.ts # Hook de persistência segura
└── types/
└── auth.ts # Tipos TypeScript
Passo 1: Hook de Persistência Segura
Primeiro, precisamos de um hook que persista o token de sessão de forma segura. No nativo, usamos expo-secure-store; na web, localStorage. Simples assim.
Instale a dependência:
npx expo install expo-secure-store
Agora crie o hook:
// src/hooks/useStorageState.ts
import { useReducer, useEffect, useCallback } from 'react';
import { Platform } from 'react-native';
import * as SecureStore from 'expo-secure-store';
type StorageState = [boolean, string | null]; // [isLoading, value]
function reducer(
_state: StorageState,
action: { type: 'loaded'; value: string | null } | { type: 'loading' }
): StorageState {
switch (action.type) {
case 'loading':
return [true, null];
case 'loaded':
return [false, action.value];
}
}
export function useStorageState(key: string): [StorageState, (value: string | null) => void] {
const [state, dispatch] = useReducer(reducer, [true, null]);
useEffect(() => {
async function load() {
try {
let value: string | null = null;
if (Platform.OS === 'web') {
value = localStorage.getItem(key);
} else {
value = await SecureStore.getItemAsync(key);
}
dispatch({ type: 'loaded', value });
} catch {
dispatch({ type: 'loaded', value: null });
}
}
load();
}, [key]);
const setValue = useCallback(
async (value: string | null) => {
try {
if (Platform.OS === 'web') {
if (value === null) {
localStorage.removeItem(key);
} else {
localStorage.setItem(key, value);
}
} else {
if (value === null) {
await SecureStore.deleteItemAsync(key);
} else {
await SecureStore.setItemAsync(key, value);
}
}
} catch (e) {
console.error('Erro ao salvar estado:', e);
}
dispatch({ type: 'loaded', value });
},
[key]
);
return [state, setValue];
}
Esse hook usa useReducer pra rastrear uma tupla [isLoading, value]. Quando o componente monta, ele carrega o valor do storage. No nativo, expo-secure-store usa o Keychain no iOS e o EncryptedSharedPreferences no Android — muito mais seguro que AsyncStorage (que, honestamente, nem deveria ser usado pra tokens).
Passo 2: Contexto de Autenticação
Agora vamos criar o provider que expõe o estado de sessão pra toda a aplicação:
// src/context/AuthContext.tsx
import { createContext, useContext, type PropsWithChildren } from 'react';
import { useStorageState } from '@/hooks/useStorageState';
interface AuthContextType {
signIn: (token: string) => void;
signOut: () => void;
session: string | null;
isLoading: boolean;
userRole: 'admin' | 'user' | null;
}
const AuthContext = createContext({
signIn: () => {},
signOut: () => {},
session: null,
isLoading: true,
userRole: null,
});
export function useSession() {
const context = useContext(AuthContext);
if (!context) {
throw new Error('useSession precisa estar dentro de um SessionProvider');
}
return context;
}
export function SessionProvider({ children }: PropsWithChildren) {
const [[isLoading, session], setSession] = useStorageState('auth-session');
const [[, role], setRole] = useStorageState('user-role');
return (
{
// Na prática, decodifique o JWT para extrair o role
setSession(token);
// Simula extração do role do token
const decodedRole = token.includes('admin') ? 'admin' : 'user';
setRole(decodedRole);
},
signOut: () => {
setSession(null);
setRole(null);
},
session,
isLoading,
userRole: (role as 'admin' | 'user') ?? null,
}}
>
{children}
);
}
Repare que incluímos userRole no contexto. Isso vai ser essencial quando implementarmos o controle de acesso baseado em roles mais adiante.
Passo 3: Layout Raiz com Stack.Protected
Essa é a parte mais importante do tutorial. O layout raiz decide quais telas o usuário pode acessar baseado no estado da sessão.
// src/app/_layout.tsx
import { Stack } from 'expo-router';
import * as SplashScreen from 'expo-splash-screen';
import { SessionProvider, useSession } from '@/context/AuthContext';
import { useEffect } from 'react';
SplashScreen.preventAutoHideAsync();
function SplashController() {
const { isLoading } = useSession();
useEffect(() => {
if (!isLoading) {
SplashScreen.hideAsync();
}
}, [isLoading]);
return null;
}
function RootNavigator() {
const { session } = useSession();
return (
);
}
export default function RootLayout() {
return (
);
}
A mágica acontece nos dois blocos Stack.Protected:
- guard={!!session} — quando há sessão, o grupo
(app)fica acessível - guard={!session} — quando não há sessão, a tela
sign-infica acessível
São mutuamente exclusivos. Quando o usuário faz login, o guard do (app) vira true e o do sign-in vira false. O redirect acontece automaticamente, sem nenhum router.replace(). Confesso que quando vi isso funcionando pela primeira vez, fiquei genuinamente impressionado com a simplicidade.
Passo 4: Tela de Login
Uma tela de login que chama a API e salva o token:
// src/app/sign-in.tsx
import { useState } from 'react';
import { View, Text, TextInput, TouchableOpacity, StyleSheet, ActivityIndicator, Alert } from 'react-native';
import { useSession } from '@/context/AuthContext';
export default function SignInScreen() {
const { signIn } = useSession();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [loading, setLoading] = useState(false);
async function handleSignIn() {
if (!email || !password) {
Alert.alert('Erro', 'Preencha todos os campos');
return;
}
setLoading(true);
try {
const response = await fetch('https://api.seuservidor.com/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
});
if (!response.ok) {
throw new Error('Credenciais inválidas');
}
const { token } = await response.json();
signIn(token);
// Não precisa de router.replace() — o Stack.Protected cuida do redirect!
} catch (error) {
Alert.alert('Erro', 'Não foi possível fazer login. Verifique suas credenciais.');
} finally {
setLoading(false);
}
}
return (
Entrar
{loading ? (
) : (
Entrar
)}
);
}
const styles = StyleSheet.create({
container: { flex: 1, justifyContent: 'center', padding: 24, backgroundColor: '#f5f5f5' },
title: { fontSize: 32, fontWeight: 'bold', marginBottom: 32, textAlign: 'center' },
input: {
backgroundColor: '#fff', borderRadius: 8, padding: 16,
marginBottom: 12, fontSize: 16, borderWidth: 1, borderColor: '#ddd',
},
button: {
backgroundColor: '#6366f1', borderRadius: 8, padding: 16,
alignItems: 'center', marginTop: 8,
},
buttonText: { color: '#fff', fontSize: 16, fontWeight: '600' },
});
Observe o comentário no código — não é necessário chamar router.replace() após o login. Quando signIn(token) atualiza o estado da sessão, o Stack.Protected reage automaticamente e redireciona pra o grupo (app). Menos código, menos bugs.
Passo 5: Layout das Telas Autenticadas
Dentro do grupo (app), definimos o layout com as telas que o usuário logado pode acessar:
// src/app/(app)/_layout.tsx
import { Stack } from 'expo-router';
import { useSession } from '@/context/AuthContext';
export default function AppLayout() {
const { userRole } = useSession();
return (
);
}
E aqui está o poder real dos guards aninhados. O grupo (admin) já está dentro do grupo (app) que exige autenticação. Agora adicionamos um guard extra que verifica se o userRole é admin. Ou seja, o usuário precisa satisfazer ambas as condições: estar logado e ser admin. Elegante, não?
Passo 6: Tela Principal com Logout
// src/app/(app)/index.tsx
import { View, Text, TouchableOpacity, StyleSheet } from 'react-native';
import { Link } from 'expo-router';
import { useSession } from '@/context/AuthContext';
export default function HomeScreen() {
const { signOut, userRole } = useSession();
return (
Bem-vindo!
Role: {userRole}
Ver Perfil
{userRole === 'admin' && (
Painel Admin
)}
Sair
);
}
const styles = StyleSheet.create({
container: { flex: 1, justifyContent: 'center', alignItems: 'center', padding: 24 },
title: { fontSize: 28, fontWeight: 'bold', marginBottom: 8 },
role: { fontSize: 16, color: '#666', marginBottom: 24 },
link: { fontSize: 18, color: '#6366f1', marginBottom: 12 },
logoutButton: {
backgroundColor: '#ef4444', borderRadius: 8, paddingHorizontal: 24,
paddingVertical: 12, marginTop: 24,
},
logoutText: { color: '#fff', fontSize: 16, fontWeight: '600' },
});
Ao chamar signOut(), a sessão é limpa, o guard do (app) vira false, e o usuário é automaticamente redirecionado pra sign-in. Zero chamadas manuais de navegação.
Protegendo Rotas em Tabs
O Stack.Protected não é exclusivo do Stack — e isso é ótimo. Você pode usar Tabs.Protected pra esconder ou mostrar abas condicionalmente. Isso é perfeito pra apps que mostram tabs diferentes pra usuários free e premium, por exemplo.
// src/app/(app)/_layout.tsx (versão com Tabs)
import { Tabs } from 'expo-router';
import { useSession } from '@/context/AuthContext';
export default function AppTabs() {
const { userRole } = useSession();
return (
null }}
/>
null }}
/>
null }}
/>
);
}
Quando userRole não é admin, a tab "Admin" simplesmente não aparece na barra de navegação. Sem renderização condicional manual, sem hacks com display: none. Só funciona.
Login como Modal (Padrão Alternativo)
Em alguns apps, faz mais sentido exibir a tela de login como um modal sobre o conteúdo existente. Esse padrão é especialmente útil quando o usuário acessa um deep link e precisa se autenticar antes de ver o conteúdo:
// src/app/(app)/_layout.tsx (modal pattern)
import { Stack } from 'expo-router';
import { useSession } from '@/context/AuthContext';
export default function AppLayout() {
const { session } = useSession();
return (
);
}
Esse padrão preserva a navegação por deep links enquanto mostra a tela de autenticação por cima das rotas existentes. Bem legal pra apps de conteúdo onde o contexto importa.
Regras Importantes do Stack.Protected
Antes de sair implementando em todo lugar, tenha em mente estas regras (eu já quebrei a cabeça com a primeira, então fica o aviso):
- Sem telas duplicadas — uma tela só pode existir em um grupo ativo por vez. Declarar o mesmo
nameem dois blocosProtecteddiferentes causa erro - Guards aninhados são cumulativos — um guard interno exige que o guard externo também seja
true - Anchor route — quando um guard falha, o router redireciona pra primeira tela não protegida disponível no stack
- Proteção apenas no lado do cliente —
Stack.Protectednão substitui autenticação no servidor. Sua API backend ainda precisa validar tokens em todas as requisições - Histórico é limpo automaticamente — quando um guard muda pra
false, todas as entradas de histórico daquelas telas protegidas são removidas
Comparação: Stack.Protected vs Redirect Condicional
Antes do Stack.Protected, a abordagem mais comum era usar redirects condicionais dentro dos layouts. Funcionava, mas dava trabalho. Veja a diferença:
| Aspecto | Redirect Condicional | Stack.Protected |
|---|---|---|
| Deep links | Podem burlar a proteção com race conditions | Proteção total, mesmo com deep links |
| Código | Checks espalhados em vários arquivos | Declarativo, centralizado no layout |
| Histórico | Precisa limpar manualmente | Limpeza automática ao desproteger |
| Manutenção | Cresce com a complexidade do app | Escala naturalmente com guards aninhados |
| Role-based | Requer lógica adicional | Basta aninhar Protected blocks |
Honestamente, depois de usar Stack.Protected, não consigo me imaginar voltando pro padrão antigo com useSegments.
Testando a Autenticação
Pra testar o fluxo completo, considere estes cenários:
// __tests__/auth-flow.test.tsx
import { renderRouter, screen } from 'expo-router/testing-library';
describe('Fluxo de Autenticação', () => {
it('redireciona para sign-in quando não autenticado', () => {
renderRouter({
initialUrl: '/',
// Mock do provider sem sessão
});
expect(screen.getByText('Entrar')).toBeTruthy();
});
it('mostra tela principal quando autenticado', () => {
renderRouter({
initialUrl: '/',
// Mock do provider com sessão válida
});
expect(screen.getByText('Bem-vindo!')).toBeTruthy();
});
it('esconde rota admin para usuários normais', () => {
renderRouter({
initialUrl: '/(admin)/dashboard',
// Mock do provider com role user
});
// Deve redirecionar para a home, não para o dashboard
expect(screen.queryByText('Dashboard')).toBeNull();
});
});
O expo-router/testing-library permite testar navegação e guards sem precisar de um emulador rodando. Isso acelera muito o ciclo de feedback durante o desenvolvimento.
Dicas de Segurança
O Stack.Protected resolve o lado do cliente da autenticação de forma elegante, mas segurança de verdade exige mais camadas. Não dá pra pular essa parte:
- Valide tokens no servidor — nunca confie apenas no client-side pra proteger dados sensíveis
- Use expo-secure-store — não armazene tokens em
AsyncStorage, que não é criptografado - Implemente refresh tokens — tokens de curta duração com refresh automático previnem uso de tokens roubados
- Limpe dados sensíveis no logout — além do token, limpe caches locais de dados do usuário
- HTTPS obrigatório — toda comunicação com a API deve ser criptografada. Sem exceções
FAQ
O Stack.Protected funciona com deep links?
Sim, e isso é uma das maiores vantagens. Diferente dos redirects condicionais que podiam ser burlados por race conditions com deep links, o Stack.Protected bloqueia a navegação no nível do router. Se um deep link aponta pra uma rota protegida e o guard é false, o usuário é automaticamente redirecionado pra anchor route.
Posso usar Stack.Protected com Drawer navigator?
Sim! O Protected está disponível em todos os navegadores do Expo Router: Stack.Protected, Tabs.Protected, e Drawer.Protected. Também funciona com qualquer navegador customizado criado com withLayoutContext.
Qual a diferença entre Stack.Protected e o redirect com useSegments?
O padrão antigo com useSegments e router.replace() exigia lógica imperativa espalhada pelos layouts. O Stack.Protected é declarativo — você define o guard no layout e o router cuida do resto. Além disso, ele limpa o histórico automaticamente e é imune a race conditions com deep links.
Preciso de alguma versão mínima do Expo SDK?
O Stack.Protected está disponível a partir do Expo SDK 53. Porém, vale usar o SDK 55 (que inclui o Expo Router v7) pra aproveitar as melhorias de performance, a New Architecture como padrão e os novos componentes de navegação nativa.
O Stack.Protected substitui autenticação no backend?
Definitivamente não. O Stack.Protected é uma proteção no lado do cliente que controla a navegação. Sua API backend ainda precisa validar tokens JWT em todas as requisições. Um atacante pode modificar o código JavaScript localmente e burlar qualquer proteção client-side. Use Stack.Protected pra UX e o backend pra segurança real.