From Zero to AI

Lesson 6.2: Bearer Tokens

Duration: 50 minutes

Learning Objectives

By the end of this lesson, you will be able to:

  1. Understand what Bearer tokens are and how they differ from API keys
  2. Use the Authorization header correctly with Bearer tokens
  3. Work with JSON Web Tokens (JWTs) and understand their structure
  4. Handle token expiration and refresh flows
  5. Implement secure token storage and transmission

Introduction

While API keys identify your application, Bearer tokens typically identify a specific user and their permissions. When you "log in with GitHub" or "connect your Google account," you receive a Bearer token that grants access to that user's data.

The word "Bearer" means "whoever holds this token has access." It is like a concert ticket - the venue does not care who you are, only that you have a valid ticket.

// API Key - identifies the application
headers: { "X-API-Key": "app-key-123" }

// Bearer Token - identifies a user session
headers: { "Authorization": "Bearer eyJhbGciOiJIUzI1NiIs..." }

What is a Bearer Token?

A Bearer token is an access credential that:

  1. Represents a user session or authenticated state
  2. Is sent in the Authorization header with the "Bearer" prefix
  3. Grants access to resources on behalf of the user
  4. Usually expires after a set time period

The Authorization Header Format

Authorization: Bearer <token>

Note the space between "Bearer" and the token. This is required.

const token = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...';

const response = await fetch('https://api.example.com/user/profile', {
  headers: {
    Authorization: `Bearer ${token}`,
  },
});

Bearer Tokens vs API Keys

Aspect API Key Bearer Token
Identifies Application User session
Lifetime Long-lived (months/years) Short-lived (minutes/hours)
Scope Fixed permissions User-specific permissions
Revocation Manual Automatic (expiration)
Format Simple string Often JWT (structured)
Obtained via Developer portal Login/OAuth flow

When to Use Each

API Keys are best for:

  • Server-to-server communication
  • Public APIs with rate limiting
  • Simple integrations without user context

Bearer Tokens are best for:

  • User-authenticated requests
  • Mobile and web applications
  • APIs that access user-specific data

JSON Web Tokens (JWT)

Most Bearer tokens you will encounter are JWTs (pronounced "jot"). A JWT is a compact, self-contained way to transmit information between parties.

JWT Structure

A JWT has three parts separated by dots:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
└─────────────────┬─────────────────┘.└──────────────────────────────┬─────────────────────────────────┘.└──────────────────┬──────────────────┘
               Header                                            Payload                                              Signature

1. Header

Contains metadata about the token:

{
  "alg": "HS256",
  "typ": "JWT"
}

2. Payload

Contains the claims (data):

{
  "sub": "1234567890",
  "name": "John Doe",
  "email": "john@example.com",
  "iat": 1516239022,
  "exp": 1516242622
}

Common claims:

  • sub (subject): User ID
  • iat (issued at): When the token was created
  • exp (expiration): When the token expires
  • iss (issuer): Who created the token

3. Signature

Verifies the token has not been tampered with.

Decoding a JWT

JWTs are encoded (base64), not encrypted. Anyone can read the payload:

function decodeJWT(token: string): { header: unknown; payload: unknown } {
  const parts = token.split('.');

  if (parts.length !== 3) {
    throw new Error('Invalid JWT format');
  }

  const decode = (str: string): unknown => {
    // Handle base64url encoding
    const base64 = str.replace(/-/g, '+').replace(/_/g, '/');
    const decoded = atob(base64);
    return JSON.parse(decoded);
  };

  return {
    header: decode(parts[0]),
    payload: decode(parts[1]),
  };
}

// Usage
const token =
  'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c';

const decoded = decodeJWT(token);
console.log(decoded.payload);
// { sub: "1234567890", name: "John Doe", iat: 1516239022 }

Important: Never put sensitive data in JWTs since anyone can decode them!


Using Bearer Tokens in Practice

Basic Request with Token

interface User {
  id: string;
  email: string;
  name: string;
}

async function getCurrentUser(token: string): Promise<User> {
  const response = await fetch('https://api.example.com/me', {
    headers: {
      Authorization: `Bearer ${token}`,
      'Content-Type': 'application/json',
    },
  });

  if (response.status === 401) {
    throw new Error('Token is invalid or expired');
  }

  if (!response.ok) {
    throw new Error(`HTTP error: ${response.status}`);
  }

  return response.json();
}

Creating an Authenticated API Client

interface TokenInfo {
  accessToken: string;
  expiresAt: number; // Unix timestamp
}

class AuthenticatedClient {
  private baseUrl: string;
  private tokenInfo: TokenInfo;

  constructor(baseUrl: string, tokenInfo: TokenInfo) {
    this.baseUrl = baseUrl.replace(/\/$/, '');
    this.tokenInfo = tokenInfo;
  }

  private isTokenExpired(): boolean {
    // Add 30 second buffer to handle clock skew
    return Date.now() >= this.tokenInfo.expiresAt * 1000 - 30000;
  }

  private getHeaders(): Record<string, string> {
    if (this.isTokenExpired()) {
      throw new Error('Token has expired. Please re-authenticate.');
    }

    return {
      Authorization: `Bearer ${this.tokenInfo.accessToken}`,
      'Content-Type': 'application/json',
    };
  }

  async get<T>(endpoint: string): Promise<T> {
    const response = await fetch(`${this.baseUrl}${endpoint}`, {
      method: 'GET',
      headers: this.getHeaders(),
    });

    return this.handleResponse<T>(response);
  }

  async post<T>(endpoint: string, data: unknown): Promise<T> {
    const response = await fetch(`${this.baseUrl}${endpoint}`, {
      method: 'POST',
      headers: this.getHeaders(),
      body: JSON.stringify(data),
    });

    return this.handleResponse<T>(response);
  }

  private async handleResponse<T>(response: Response): Promise<T> {
    if (response.status === 401) {
      throw new Error('Authentication failed. Token may be invalid.');
    }

    if (response.status === 403) {
      throw new Error('Access denied. Insufficient permissions.');
    }

    if (!response.ok) {
      const error = await response.text();
      throw new Error(`Request failed: ${error}`);
    }

    return response.json();
  }
}

Token Expiration and Refresh

Bearer tokens typically expire for security reasons. When a token expires, you need to either:

  1. Re-authenticate the user (redirect to login)
  2. Refresh the token using a refresh token

Understanding Token Expiration

interface JWTPayload {
  sub: string;
  exp: number; // Expiration timestamp
  iat: number; // Issued at timestamp
}

function isTokenExpired(token: string): boolean {
  try {
    const parts = token.split('.');
    const payload = JSON.parse(atob(parts[1])) as JWTPayload;

    // exp is in seconds, Date.now() is in milliseconds
    return Date.now() >= payload.exp * 1000;
  } catch {
    return true; // Treat invalid tokens as expired
  }
}

function getTokenExpirationDate(token: string): Date | null {
  try {
    const parts = token.split('.');
    const payload = JSON.parse(atob(parts[1])) as JWTPayload;
    return new Date(payload.exp * 1000);
  } catch {
    return null;
  }
}

// Usage
const token = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...';

if (isTokenExpired(token)) {
  console.log('Token has expired, need to refresh or re-login');
} else {
  const expiresAt = getTokenExpirationDate(token);
  console.log(`Token expires at: ${expiresAt}`);
}

Refresh Token Flow

Many APIs provide two tokens:

  • Access token: Short-lived (15 min - 1 hour), used for API requests
  • Refresh token: Long-lived (days - weeks), used to get new access tokens
interface TokenPair {
  accessToken: string;
  refreshToken: string;
  expiresIn: number; // seconds until access token expires
}

async function refreshAccessToken(refreshToken: string): Promise<TokenPair> {
  const response = await fetch('https://api.example.com/auth/refresh', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({ refreshToken }),
  });

  if (!response.ok) {
    throw new Error('Failed to refresh token. Please log in again.');
  }

  return response.json();
}

Auto-Refreshing Client

class AutoRefreshClient {
  private baseUrl: string;
  private accessToken: string;
  private refreshToken: string;
  private expiresAt: number;

  constructor(baseUrl: string, tokens: TokenPair) {
    this.baseUrl = baseUrl;
    this.accessToken = tokens.accessToken;
    this.refreshToken = tokens.refreshToken;
    this.expiresAt = Date.now() + tokens.expiresIn * 1000;
  }

  private async ensureValidToken(): Promise<void> {
    // Refresh if token expires in less than 1 minute
    if (Date.now() >= this.expiresAt - 60000) {
      console.log('Token expiring soon, refreshing...');

      const newTokens = await refreshAccessToken(this.refreshToken);

      this.accessToken = newTokens.accessToken;
      this.refreshToken = newTokens.refreshToken;
      this.expiresAt = Date.now() + newTokens.expiresIn * 1000;

      console.log('Token refreshed successfully');
    }
  }

  async request<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
    await this.ensureValidToken();

    const response = await fetch(`${this.baseUrl}${endpoint}`, {
      ...options,
      headers: {
        ...options.headers,
        Authorization: `Bearer ${this.accessToken}`,
        'Content-Type': 'application/json',
      },
    });

    if (response.status === 401) {
      // Token was rejected despite our check - force refresh
      const newTokens = await refreshAccessToken(this.refreshToken);
      this.accessToken = newTokens.accessToken;
      this.refreshToken = newTokens.refreshToken;
      this.expiresAt = Date.now() + newTokens.expiresIn * 1000;

      // Retry the request
      const retryResponse = await fetch(`${this.baseUrl}${endpoint}`, {
        ...options,
        headers: {
          ...options.headers,
          Authorization: `Bearer ${this.accessToken}`,
          'Content-Type': 'application/json',
        },
      });

      if (!retryResponse.ok) {
        throw new Error(`Request failed after token refresh: ${retryResponse.status}`);
      }

      return retryResponse.json();
    }

    if (!response.ok) {
      throw new Error(`Request failed: ${response.status}`);
    }

    return response.json();
  }
}

Security Best Practices

1. Never Expose Tokens in URLs

// BAD - Token visible in URL, logs, browser history
fetch(`https://api.example.com/data?token=${token}`);

// GOOD - Token in header, not visible
fetch('https://api.example.com/data', {
  headers: { Authorization: `Bearer ${token}` },
});

2. Use HTTPS Only

Tokens sent over HTTP can be intercepted. Always use HTTPS:

function validateUrl(url: string): void {
  if (!url.startsWith('https://')) {
    throw new Error('Tokens must only be sent over HTTPS');
  }
}

3. Store Tokens Securely

For Node.js applications:

// Use environment variables
const token = process.env.ACCESS_TOKEN;

For browser applications:

// Prefer httpOnly cookies (set by server) over localStorage
// If you must use storage, prefer sessionStorage over localStorage
sessionStorage.setItem('token', token);

4. Handle Token Leaks

If a token is compromised:

  1. Revoke it immediately (if the API supports it)
  2. Generate new tokens
  3. Investigate how the leak occurred
  4. Implement additional security measures

5. Validate Token Expiration Client-Side

Always check expiration before using a token:

function getValidToken(token: string): string {
  if (isTokenExpired(token)) {
    throw new Error('Token expired');
  }
  return token;
}

Practical Example: GitHub API with Token

GitHub uses Bearer tokens (Personal Access Tokens) for authentication:

interface GitHubUser {
  login: string;
  id: number;
  name: string;
  email: string | null;
  public_repos: number;
  followers: number;
  following: number;
}

interface GitHubRepo {
  id: number;
  name: string;
  full_name: string;
  private: boolean;
  description: string | null;
  stargazers_count: number;
}

class GitHubClient {
  private token: string;
  private baseUrl = 'https://api.github.com';

  constructor(token: string) {
    this.token = token;
  }

  private async request<T>(endpoint: string): Promise<T> {
    const response = await fetch(`${this.baseUrl}${endpoint}`, {
      headers: {
        Authorization: `Bearer ${this.token}`,
        Accept: 'application/vnd.github.v3+json',
        'User-Agent': 'TypeScript-Course-App',
      },
    });

    if (response.status === 401) {
      throw new Error('Invalid GitHub token');
    }

    if (response.status === 403) {
      const remaining = response.headers.get('X-RateLimit-Remaining');
      if (remaining === '0') {
        const resetTime = response.headers.get('X-RateLimit-Reset');
        throw new Error(`Rate limit exceeded. Resets at ${new Date(Number(resetTime) * 1000)}`);
      }
      throw new Error('Access forbidden');
    }

    if (!response.ok) {
      throw new Error(`GitHub API error: ${response.status}`);
    }

    return response.json();
  }

  async getCurrentUser(): Promise<GitHubUser> {
    return this.request<GitHubUser>('/user');
  }

  async listRepositories(): Promise<GitHubRepo[]> {
    return this.request<GitHubRepo[]>('/user/repos?sort=updated&per_page=10');
  }

  async getRepository(owner: string, repo: string): Promise<GitHubRepo> {
    return this.request<GitHubRepo>(`/repos/${owner}/${repo}`);
  }
}

// Usage
async function main() {
  const token = process.env.GITHUB_TOKEN;

  if (!token) {
    console.error('Please set GITHUB_TOKEN environment variable');
    process.exit(1);
  }

  const github = new GitHubClient(token);

  try {
    const user = await github.getCurrentUser();
    console.log(`Logged in as: ${user.login} (${user.name})`);
    console.log(`Public repos: ${user.public_repos}`);
    console.log(`Followers: ${user.followers}`);

    console.log('\nRecent repositories:');
    const repos = await github.listRepositories();

    for (const repo of repos.slice(0, 5)) {
      console.log(`  - ${repo.name}: ${repo.stargazers_count} stars`);
    }
  } catch (error) {
    console.error('Error:', error instanceof Error ? error.message : error);
  }
}

main();

Exercises

Exercise 1: JWT Decoder

Create a function that safely decodes and validates a JWT:

interface DecodedJWT {
  header: Record<string, unknown>;
  payload: Record<string, unknown>;
  isExpired: boolean;
  expiresAt: Date | null;
}

function decodeAndValidateJWT(token: string): DecodedJWT {}
Solution
interface DecodedJWT {
  header: Record<string, unknown>;
  payload: Record<string, unknown>;
  isExpired: boolean;
  expiresAt: Date | null;
}

function decodeAndValidateJWT(token: string): DecodedJWT {
  const parts = token.split('.');

  if (parts.length !== 3) {
    throw new Error('Invalid JWT format: must have 3 parts separated by dots');
  }

  const decodeBase64Url = (str: string): string => {
    // Convert base64url to base64
    let base64 = str.replace(/-/g, '+').replace(/_/g, '/');

    // Add padding if needed
    while (base64.length % 4) {
      base64 += '=';
    }

    return atob(base64);
  };

  let header: Record<string, unknown>;
  let payload: Record<string, unknown>;

  try {
    header = JSON.parse(decodeBase64Url(parts[0]));
  } catch {
    throw new Error('Invalid JWT: could not decode header');
  }

  try {
    payload = JSON.parse(decodeBase64Url(parts[1]));
  } catch {
    throw new Error('Invalid JWT: could not decode payload');
  }

  // Check expiration
  let isExpired = false;
  let expiresAt: Date | null = null;

  if (typeof payload.exp === 'number') {
    expiresAt = new Date(payload.exp * 1000);
    isExpired = Date.now() >= payload.exp * 1000;
  }

  return {
    header,
    payload,
    isExpired,
    expiresAt,
  };
}

// Test
const testToken =
  'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiZXhwIjoxNzA0MDY3MjAwfQ.signature';

try {
  const decoded = decodeAndValidateJWT(testToken);
  console.log('Header:', decoded.header);
  console.log('Payload:', decoded.payload);
  console.log('Expired:', decoded.isExpired);
  console.log('Expires at:', decoded.expiresAt);
} catch (error) {
  console.error('Error:', error);
}

Exercise 2: Token Manager

Create a class that manages token storage and automatic refresh:

class TokenManager {
  // Should store access and refresh tokens
  // Should provide method to get valid token
  // Should automatically refresh when needed
  // Should emit events for token refresh and expiration
}
Solution
type TokenEventType = 'refreshed' | 'expired' | 'error';
type TokenEventCallback = (event: { type: TokenEventType; data?: unknown }) => void;

interface StoredTokens {
  accessToken: string;
  refreshToken: string;
  expiresAt: number;
}

class TokenManager {
  private accessToken: string | null = null;
  private refreshToken: string | null = null;
  private expiresAt: number = 0;
  private refreshEndpoint: string;
  private listeners: Map<TokenEventType, TokenEventCallback[]> = new Map();
  private refreshPromise: Promise<void> | null = null;

  constructor(refreshEndpoint: string) {
    this.refreshEndpoint = refreshEndpoint;
  }

  setTokens(accessToken: string, refreshToken: string, expiresIn: number): void {
    this.accessToken = accessToken;
    this.refreshToken = refreshToken;
    this.expiresAt = Date.now() + expiresIn * 1000;
  }

  on(event: TokenEventType, callback: TokenEventCallback): void {
    if (!this.listeners.has(event)) {
      this.listeners.set(event, []);
    }
    this.listeners.get(event)!.push(callback);
  }

  private emit(type: TokenEventType, data?: unknown): void {
    const callbacks = this.listeners.get(type) || [];
    callbacks.forEach((cb) => cb({ type, data }));
  }

  private isExpiringSoon(): boolean {
    // Consider token expiring if less than 2 minutes remaining
    return Date.now() >= this.expiresAt - 120000;
  }

  private async doRefresh(): Promise<void> {
    if (!this.refreshToken) {
      throw new Error('No refresh token available');
    }

    try {
      const response = await fetch(this.refreshEndpoint, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ refreshToken: this.refreshToken }),
      });

      if (!response.ok) {
        this.accessToken = null;
        this.refreshToken = null;
        this.emit('expired');
        throw new Error('Token refresh failed');
      }

      const data = await response.json();

      this.accessToken = data.accessToken;
      this.refreshToken = data.refreshToken;
      this.expiresAt = Date.now() + data.expiresIn * 1000;

      this.emit('refreshed', { expiresAt: this.expiresAt });
    } catch (error) {
      this.emit('error', error);
      throw error;
    }
  }

  async getAccessToken(): Promise<string> {
    if (!this.accessToken) {
      throw new Error('Not authenticated');
    }

    if (this.isExpiringSoon()) {
      // Prevent multiple simultaneous refresh attempts
      if (!this.refreshPromise) {
        this.refreshPromise = this.doRefresh().finally(() => {
          this.refreshPromise = null;
        });
      }
      await this.refreshPromise;
    }

    return this.accessToken;
  }

  async makeAuthenticatedRequest<T>(url: string, options: RequestInit = {}): Promise<T> {
    const token = await this.getAccessToken();

    const response = await fetch(url, {
      ...options,
      headers: {
        ...options.headers,
        Authorization: `Bearer ${token}`,
      },
    });

    if (!response.ok) {
      throw new Error(`Request failed: ${response.status}`);
    }

    return response.json();
  }

  clear(): void {
    this.accessToken = null;
    this.refreshToken = null;
    this.expiresAt = 0;
  }
}

// Usage
const tokenManager = new TokenManager('https://api.example.com/auth/refresh');

tokenManager.on('refreshed', (event) => {
  console.log('Token was refreshed');
});

tokenManager.on('expired', () => {
  console.log('Session expired, redirecting to login...');
});

// After login:
tokenManager.setTokens('access123', 'refresh456', 3600);

// Making requests:
async function fetchData() {
  const data = await tokenManager.makeAuthenticatedRequest<{ name: string }>(
    'https://api.example.com/user'
  );
  console.log(data.name);
}

Exercise 3: Retry with Token Refresh

Create a fetch wrapper that automatically retries on 401 errors after refreshing the token:

async function fetchWithTokenRefresh<T>(
  url: string,
  options: RequestInit,
  tokenManager: TokenManager
): Promise<T> {
  // Should try request
  // On 401, refresh token and retry once
  // On second 401, throw error
}
Solution
interface SimpleTokenManager {
  getAccessToken(): Promise<string>;
  refreshToken(): Promise<void>;
}

async function fetchWithTokenRefresh<T>(
  url: string,
  options: RequestInit,
  tokenManager: SimpleTokenManager
): Promise<T> {
  const makeRequest = async (token: string): Promise<Response> => {
    return fetch(url, {
      ...options,
      headers: {
        ...options.headers,
        Authorization: `Bearer ${token}`,
        'Content-Type': 'application/json',
      },
    });
  };

  // First attempt
  let token = await tokenManager.getAccessToken();
  let response = await makeRequest(token);

  // If unauthorized, try refreshing token
  if (response.status === 401) {
    console.log('Got 401, attempting token refresh...');

    try {
      await tokenManager.refreshToken();
      token = await tokenManager.getAccessToken();
      response = await makeRequest(token);
    } catch (refreshError) {
      throw new Error('Token refresh failed. Please log in again.');
    }

    // If still 401 after refresh, give up
    if (response.status === 401) {
      throw new Error('Authentication failed after token refresh');
    }
  }

  if (!response.ok) {
    throw new Error(`Request failed: ${response.status}`);
  }

  return response.json();
}

// Usage example
class MockTokenManager implements SimpleTokenManager {
  private token = 'initial-token';
  private refreshCount = 0;

  async getAccessToken(): Promise<string> {
    return this.token;
  }

  async refreshToken(): Promise<void> {
    this.refreshCount++;
    this.token = `refreshed-token-${this.refreshCount}`;
    console.log(`Token refreshed: ${this.token}`);
  }
}

async function example() {
  const tokenManager = new MockTokenManager();

  try {
    const data = await fetchWithTokenRefresh<{ name: string }>(
      'https://api.example.com/user',
      { method: 'GET' },
      tokenManager
    );
    console.log('User:', data.name);
  } catch (error) {
    console.error('Error:', error);
  }
}

Key Takeaways

  1. Bearer tokens authenticate user sessions, sent in the Authorization: Bearer <token> header
  2. Most Bearer tokens are JWTs with header, payload, and signature parts
  3. JWTs are encoded, not encrypted - do not store sensitive data in them
  4. Tokens expire for security - implement refresh flows to maintain sessions
  5. Use refresh tokens to get new access tokens without re-authenticating
  6. Always send tokens over HTTPS and store them securely
  7. Check expiration client-side before making requests

Resources

Resource Type Level
JWT.io Tool Beginner
RFC 6750: Bearer Token Usage Specification Advanced
MDN: Authorization Header Documentation Beginner
Auth0: JWT Introduction Tutorial Beginner

Next Lesson

Bearer tokens are often obtained through OAuth, a protocol that allows users to grant your application access to their data. Let us explore how OAuth works.

Continue to Lesson 6.3: OAuth Basics