Courses/Grit Mobile/Mobile Auth & Navigation
Course 2 of 5~30 min12 challenges

Mobile Auth & Navigation

Mobile authentication is fundamentally different from the web. There are no cookies and no localStorage. In this course, you will learn how to store tokens securely, build login and logout flows, protect routes, and create tab and stack navigation with Expo Router.


Auth in Mobile Apps

On the web, you store JWT tokens in cookies or localStorage. On mobile, neither exists. Instead, mobile apps use encrypted device storage that is protected by the operating system itself.

SecureStore (expo-secure-store): Encrypted key-value storage provided by Expo. On iOS, it stores data in the iOS Keychain. On Android, it uses the Android Keystore. Tokens stored here are encrypted at rest and only accessible to your app — even if the device is stolen, the data cannot be read without the device passcode.

This means your authentication tokens are more secure on mobile than on web. The Keychain/Keystore is hardware-backed on modern devices, making it extremely difficult to extract tokens.

1

Challenge: Understand the Difference

Explain in your own words why mobile apps cannot use cookies or localStorage. What does expo-secure-store use instead on iOS vs Android?

Login Flow

The login flow on mobile follows four steps:

  1. 1. User enters email and password on the login screen
  2. 2. App calls POST /api/auth/login with the credentials
  3. 3. Store the returned tokens in SecureStore
  4. 4. Navigate to the home screen
import * as SecureStore from 'expo-secure-store'

async function handleLogin(email: string, password: string) {
  const response = await fetch(API_URL + "/api/auth/login", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ email, password }),
  })
  const data = await response.json()

  // Store tokens in encrypted storage
  await SecureStore.setItemAsync('access_token', data.access_token)
  await SecureStore.setItemAsync('refresh_token', data.refresh_token)

  // Navigate to home
  router.replace('/(tabs)')
}
router.replace() is used instead of router.push() so the user cannot press the back button to return to the login screen after logging in.
2

Challenge: Find Token Storage

Find where tokens are stored in the scaffolded code. What key names are used for the access token and refresh token? What file contains this logic?

3

Challenge: Test the Login

Register a user (if you haven't already), then log in. Check the API server logs — do you see the login request? Does the app navigate away from the login screen?

Auth Context

The auth state needs to be accessible from any screen in the app. Grit uses the React Context pattern — an AuthProvider wraps the entire app and provides auth state to all screens through a useAuth hook.

// hooks/useAuth.tsx
interface AuthContextType {
  user: User | null
  isLoading: boolean
  isAuthenticated: boolean
  login: (email: string, password: string) => Promise<void>
  register: (email: string, password: string, name: string) => Promise<void>
  logout: () => Promise<void>
}

// In any screen:
const { user, isAuthenticated, logout } = useAuth()

The AuthProvider reads tokens from SecureStore on app launch. If valid tokens exist, the user is automatically logged in. If not, they see the login screen.

4

Challenge: Find the Auth Context

Find the auth context or useAuth hook in the scaffolded code. What values does it provide? Where is the AuthProvider mounted?

Protected Routes

Screens inside (tabs)/ should only be accessible to logged-in users. The tab layout checks the auth state and redirects to login if the user is not authenticated:

// app/(tabs)/_layout.tsx
import { Redirect } from 'expo-router'
import { useAuth } from '@/hooks/useAuth'

export default function TabLayout() {
  const { user, isLoading } = useAuth()

  // Show loading screen while checking auth
  if (isLoading) return <LoadingScreen />

  // Redirect to login if not authenticated
  if (!user) return <Redirect href="/login" />

  return (
    <Tabs>
      <Tabs.Screen name="index" options={{ title: "Home" }} />
      <Tabs.Screen name="profile" options={{ title: "Profile" }} />
      <Tabs.Screen name="settings" options={{ title: "Settings" }} />
    </Tabs>
  )
}
Protected Route: A screen that requires authentication to access. If a user who is not logged in tries to open a protected route, they are automatically redirected to the login screen. After logging in, they return to the screen they originally tried to access.
5

Challenge: Test Protected Routes

Log out of the app and try to access a protected screen (like the home tab). Does it redirect you to the login screen? Log in again — do you return to the protected screen?

Expo Router Navigation

Mobile apps use two primary navigation patterns:

Tab Navigation: A bar at the bottom of the screen with icons for each main section (Home, Search, Profile, etc.). The user taps a tab to switch between sections. This is the primary navigation pattern in most mobile apps — think Instagram, Twitter, or Spotify.
Stack Navigation: Screens that stack on top of each other. When you tap an item in a list, a detail screen slides in from the right. Press the back button to go back. This is used for drill-down flows within a tab.

Here's how the file structure maps to navigation:

app/
├── _layout.tsx          ← Root layout (Stack)
├── login.tsx            ← Login screen
├── register.tsx         ← Register screen
└── (tabs)/
    ├── _layout.tsx      ← Tab bar layout
    ├── index.tsx         ← Home tab
    ├── profile.tsx       ← Profile tab
    └── settings.tsx      ← Settings tab

The root layout uses a Stack navigator. Login and register are stack screens. The (tabs)/ group contains a Tab navigator with three tabs. This means you can have stack navigation (push/pop screens) at the root level, and tab navigation inside the authenticated area.

6

Challenge: Add a New Tab

Create a new file at app/(tabs)/explore.tsx with a simple screen component. Then add it to the (tabs)/_layout.tsx tab configuration. Does it appear in the tab bar?

7

Challenge: Stack Inside a Tab

Inside one of your tabs, add a button that navigates to a detail screen using router.push(). Does the back button appear? Can you navigate back?

Token Refresh

The access token expires in 15 minutes. When it expires, the API returns a401 Unauthorized response. Instead of logging the user out, the API client automatically uses the refresh token to get a new access token.

// lib/api.ts — simplified interceptor pattern
async function fetchWithAuth(url: string, options: RequestInit = {}) {
  const token = await SecureStore.getItemAsync('access_token')

  const response = await fetch(url, {
    ...options,
    headers: { ...options.headers, Authorization: "Bearer " + token },
  })

  // If token expired, refresh and retry
  if (response.status === 401) {
    const newToken = await refreshAccessToken()
    if (newToken) {
      return fetch(url, {
        ...options,
        headers: { ...options.headers, Authorization: "Bearer " + newToken },
      })
    }
  }

  return response
}

async function refreshAccessToken(): Promise<string | null> {
  const refreshToken = await SecureStore.getItemAsync('refresh_token')
  const response = await fetch(API_URL + "/api/auth/refresh", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ refresh_token: refreshToken }),
  })
  const data = await response.json()
  if (data.access_token) {
    await SecureStore.setItemAsync('access_token', data.access_token)
    return data.access_token
  }
  return null
}
Token Refresh: The process of exchanging an expired access token for a new one using a longer-lived refresh token. This happens automatically in the background — the user never sees a login screen unless their refresh token has also expired (typically after 7 days).
8

Challenge: Find the Refresh Logic

Look at the API client in the scaffolded code. Can you find the refresh token logic? What happens if the refresh token itself has expired?

Logout

Logging out involves three steps: clear tokens from SecureStore, reset the auth state, and navigate to the login screen.

async function logout() {
  // 1. Clear tokens from encrypted storage
  await SecureStore.deleteItemAsync('access_token')
  await SecureStore.deleteItemAsync('refresh_token')

  // 2. Reset auth state
  setUser(null)

  // 3. Navigate to login
  router.replace('/login')
}
Always use router.replace() when navigating to login after logout. This prevents the user from pressing the back button to return to a protected screen.
9

Challenge: Test Logout

Log in, then log out. After logging out, try pressing the back button. Can you access the protected screens? You should not be able to.

Summary

Here's what you learned in this course:

  • Mobile apps use SecureStore (encrypted device storage) instead of cookies
  • The auth context provides user state to all screens via useAuth
  • Protected routes redirect unauthenticated users to login
  • Tab navigation is the primary pattern; stack navigation is for drill-downs
  • Token refresh happens automatically — users stay logged in seamlessly
10

Challenge: Build a Complete Auth Flow

Verify your app has: login screen, register screen, protected tab navigation (Home, Profile, Settings), and a logout button in the settings or profile screen.

11

Challenge: Add a New Protected Screen

Add a "Workouts" tab to the tab bar. It should show a simple list screen. Verify that it's protected — logging out and accessing it directly should redirect to login.

12

Challenge: Test Token Expiry

If possible, set the access token expiry to a short duration (e.g., 1 minute) in the API config. Use the app until the token expires. Does the refresh happen transparently? Does the app stay logged in?