Rotas Protegidas no Expo Router v7 com Stack.Protected: Guia Completo de Autenticação

Implemente autenticação completa em React Native com Stack.Protected do Expo Router v7. Login persistente, controle de acesso por roles e proteção em tabs — com código pronto pra usar no seu projeto.

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-in fica 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):

  1. Sem telas duplicadas — uma tela só pode existir em um grupo ativo por vez. Declarar o mesmo name em dois blocos Protected diferentes causa erro
  2. Guards aninhados são cumulativos — um guard interno exige que o guard externo também seja true
  3. Anchor route — quando um guard falha, o router redireciona pra primeira tela não protegida disponível no stack
  4. Proteção apenas no lado do clienteStack.Protected não substitui autenticação no servidor. Sua API backend ainda precisa validar tokens em todas as requisições
  5. 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.

Sobre o Autor Editorial Team

Our team of expert writers and editors.