Lesson 6.2: Bearer Tokens
Duration: 50 minutes
Learning Objectives
By the end of this lesson, you will be able to:
- Understand what Bearer tokens are and how they differ from API keys
- Use the Authorization header correctly with Bearer tokens
- Work with JSON Web Tokens (JWTs) and understand their structure
- Handle token expiration and refresh flows
- 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:
- Represents a user session or authenticated state
- Is sent in the Authorization header with the "Bearer" prefix
- Grants access to resources on behalf of the user
- 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 IDiat(issued at): When the token was createdexp(expiration): When the token expiresiss(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:
- Re-authenticate the user (redirect to login)
- 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:
- Revoke it immediately (if the API supports it)
- Generate new tokens
- Investigate how the leak occurred
- 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
- Bearer tokens authenticate user sessions, sent in the
Authorization: Bearer <token>header - Most Bearer tokens are JWTs with header, payload, and signature parts
- JWTs are encoded, not encrypted - do not store sensitive data in them
- Tokens expire for security - implement refresh flows to maintain sessions
- Use refresh tokens to get new access tokens without re-authenticating
- Always send tokens over HTTPS and store them securely
- 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.