How to Refresh Expired Tokens in Swift — With URLSession, Alamofire & Moya Examples
401 Unauthorized? Not on your watch. Learn how to refresh tokens like a pro in Swift — with working examples in URLSession, Alamofire, and…

✍️ Introduction
Imagine this: your user logs in, makes coffee, binge-watches an episode, comes back to your app… and boom 💥 — 401 Unauthorized. The access token’s expired.
Welcome to the wonderful world of token expiry.
Now if your app just shows an error and gives up, your users will too. 😬
Instead, what your app should do is:
- Catch that 401 response like a pro ⚠️
- Quietly refresh the token 🔁
- Retry the failed request — like nothing ever happened ✨
In this guide, we’ll break down how to handle expired tokens and refresh them automatically using three popular networking approaches in Swift:
- 🧱 URLSession — the raw, no-magic standard library way
- 🚀 Alamofire — with powerful RequestInterceptor handling
- 🧩 Moya — with clean plugin-based abstractions
Whether you’re building with vanilla URLSession or have gone full framework-mode with Moya, you’ll find real code examples that show how to:
- Detect expired tokens (hello, 401 👋)
- Perform a refresh using your refresh token
- Retry the original request — securely and seamlessly
So grab your caffeine of choice ☕, and let’s make your networking layer a little smarter (and your users a little happier).
🧠 Why Do Tokens Expire?
APIs expire tokens for one simple reason: security. An access token is like a guest pass — if it lasts forever, it’s a risk. So, they’re short-lived by design.
But to avoid bothering users every hour, we get a refresh token too. It’s like a backstage pass the app can quietly use to grab a fresh access token when needed.
Here’s what typically happens:
- Access token expires
- Your app hits an API → gets a 401 Unauthorized
- It should then call the refresh endpoint
- Get a new token and retry the original request — seamlessly
Skip this flow, and your users may end up force-logged out without warning. Not a great look.
👀 If you’re wondering whether URLSession, Alamofire, or Moya is the best fit for your app, this _comparison article_ will help you decide.
**Moya vs Alamofire vs URLSession in Swift: Which One Should You Choose and Why?** _⚔️ Confused Between Moya, Alamofire, and URLSession in Swift? Discover the pros, cons, and best use cases for each…_medium.comhttps://medium.com/swift-pal/moya-vs-alamofire-vs-urlsession-in-swift-which-one-should-you-choose-and-why-d2cc3325299c
Let’s start with how to do this using URLSession — the most hands-on (but flexible) approach.
🔧 Refreshing Tokens Using URLSession
Using URLSession means you’re doing things the manual way — no built-in magic, but total control. Which is great… unless you forget to handle token expiry and your app keeps firing 401s like a stubborn toddler pressing the same elevator button. 😵
Let’s walk through how to make URLSession smart enough to:
- Detect when a token has expired (usually via a 401 response)
- Refresh the token using your refresh token
- Retry the original request with the new access token
🧪 Step 1: Detect Expired Token (401)
Make your API call and check if the server responds with 401 — which usually means your access token is no longer valid.
func performRequest() async {
var request = URLRequest(url: URL(string: "https://api.example.com/user")!)
request.setValue("Bearer \(TokenStorage.shared.accessToken)", forHTTPHeaderField: "Authorization")
do {
let (_, response) = try await URLSession.shared.data(for: request)
if let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 401 {
// Token expired — refresh needed
try await handleTokenRefreshAndRetry(originalRequest: request)
}
} catch {
print("Network error: \(error)")
}
}🔄 Step 2: Refresh the Token
If the token has expired, call your refresh endpoint and update your stored access token.
func refreshAccessToken() async throws {
var request = URLRequest(url: URL(string: "https://api.example.com/auth/refresh")!)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
let body = ["refresh_token": TokenStorage.shared.refreshToken]
request.httpBody = try JSONEncoder().encode(body)
let (data, response) = try await URLSession.shared.data(for: request)
guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else {
throw URLError(.userAuthenticationRequired)
}
let decoded = try JSONDecoder().decode(TokenResponse.self, from: data)
TokenStorage.shared.accessToken = decoded.accessToken
}🔁 Step 3: Retry the Original Request
After the token is refreshed, retry the request that failed — now with the new token.
func handleTokenRefreshAndRetry(originalRequest: URLRequest) async throws {
try await refreshAccessToken()
var newRequest = originalRequest
newRequest.setValue("Bearer \(TokenStorage.shared.accessToken)", forHTTPHeaderField: "Authorization")
let (data, _) = try await URLSession.shared.data(for: newRequest)
// Parse and use the data as needed
print("Retried request succeeded with data: \(data)")
}🧠 Pro Tip
When multiple requests fail at once (because the token expired mid-scroll), don’t refresh five times. Queue them up, wait for the refresh to complete once, then retry all of them. No chaos, just class ✨
And hey — if you’re building your own URLSession layer or want to unit test this stuff cleanly, check out this guide for a more scalable architecture.
**URLSession in Swift: Build a Clean and Testable Networking Layer** _Tired of tangled networking code in your iOS apps? Learn how to build a clean, testable, and scalable networking layer…_medium.comhttps://medium.com/swift-pal/urlsession-in-swift-build-a-clean-and-testable-networking-layer-261f96a3df63
Alright, URLSession is handled like a champ. Now let’s bring in the big guns — Alamofire, with its mighty RequestInterceptor.
🚀 Refreshing Tokens Using Alamofire
Alamofire makes a lot of things easier — and token refreshing is no exception. Instead of manually checking for 401s and retrying failed requests, you can use something called a RequestInterceptor. Think of it as your personal bodyguard that steps in when things go wrong.
We’ll use it to:
- Add the access token to every request
- Detect when the token has expired
- Refresh it automatically
- Retry the original request with the new token — like nothing ever happened 😌
🧩 Step 1: Create a TokenInterceptor
class TokenInterceptor: RequestInterceptor {
private var isRefreshing = false
private var retryQueue: [(RetryResult) -> Void] = []
func adapt(_ urlRequest: URLRequest, for session: Session, completion: @escaping (Result<URLRequest, Error>) -> Void) {
var request = urlRequest
request.setValue("Bearer \(TokenStorage.shared.accessToken)", forHTTPHeaderField: "Authorization")
completion(.success(request))
}
func retry(_ request: Request, for session: Session, dueTo error: Error, completion: @escaping (RetryResult) -> Void) {
guard let response = request.task?.response as? HTTPURLResponse, response.statusCode == 401 else {
return completion(.doNotRetry)
}
retryQueue.append(completion)
guard !isRefreshing else { return }
isRefreshing = true
Task {
do {
try await refreshAccessToken()
retryQueue.forEach { $0(.retry) }
} catch {
retryQueue.forEach { $0(.doNotRetry) }
}
retryQueue.removeAll()
isRefreshing = false
}
}
}🔄 Step 2: Implement Token Refresh Logic in TokenInterceptor
Same as before — use async/await for the refresh call:
func refreshAccessToken() async throws {
let url = URL(string: "https://api.example.com/auth/refresh")!
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
let body = ["refresh_token": TokenStorage.shared.refreshToken]
request.httpBody = try JSONEncoder().encode(body)
let (data, response) = try await URLSession.shared.data(for: request)
guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else {
throw URLError(.userAuthenticationRequired)
}
let token = try JSONDecoder().decode(TokenResponse.self, from: data)
TokenStorage.shared.accessToken = token.accessToken
}📡 Step 3: Use It with Alamofire
Set up Alamofire’s session with your custom interceptor:
let session = Session(interceptor: TokenInterceptor())
session.request("https://api.example.com/user")
.validate()
.responseDecodable(of: UserProfile.self) { response in
switch response.result {
case .success(let profile):
print("Hello, \(profile.name)")
case .failure(let error):
print("API Error: \(error)")
}
}🧠 Quick Tip
This interceptor logic runs for every request, so make sure the retry queue is thread-safe. Also, don’t forget to invalidate the refresh token if that fails — don’t keep retrying forever or you’ll end up in a loop of doom 🔁☠️
That’s Alamofire — clean, centralized, and much easier to manage than juggling URLSessions manually.
🧩 Refreshing Tokens Using Moya
If Alamofire is the toolbox, Moya is the fully furnished workshop. 🧰
It sits on top of Alamofire and gives you a neat, declarative way to define APIs. But just like any abstracted tool, you still need to wire in your own logic for handling token expiry and refresh.
In Moya, the best way to handle this is through a custom plugin. This lets you:
- Inject the latest access token into requests
- Intercept responses to detect 401s
- Refresh the token and retry the request — all behind the scenes
Let’s build it out.
🧩 Step 1: Create a Plugin for Token Handling
final class AuthPlugin: PluginType {
private var isRefreshing = false
private var retryQueue: [(MoyaProvider<MyAPI>, TargetType) -> Void] = []
func prepare(_ request: URLRequest, target: TargetType) -> URLRequest {
var request = request
request.setValue("Bearer \(TokenStorage.shared.accessToken)", forHTTPHeaderField: "Authorization")
return request
}
func didReceive(_ result: Result<Response, MoyaError>, target: TargetType) {
guard case let .failure(error) = result,
case let .statusCode(response) = error,
response.statusCode == 401,
let target = target as? MyAPI else { return }
retryQueue.append { provider, target in
provider.request(target) { result in
switch result {
case .success(let response):
print("Retried and succeeded: \(response.statusCode)")
case .failure(let error):
print("Retry failed: \(error)")
}
}
}
guard !isRefreshing else { return }
isRefreshing = true
Task {
do {
try await refreshAccessToken()
let provider = MoyaProvider<MyAPI>(plugins: [self])
retryQueue.forEach { $0(provider, target) }
} catch {
print("Token refresh failed: \(error)")
}
retryQueue.removeAll()
isRefreshing = false
}
}
private func refreshAccessToken() async throws {
var request = URLRequest(url: URL(string: "https://api.example.com/auth/refresh")!)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
let body = ["refresh_token": TokenStorage.shared.refreshToken]
request.httpBody = try JSONEncoder().encode(body)
let (data, response) = try await URLSession.shared.data(for: request)
guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else {
throw URLError(.userAuthenticationRequired)
}
let token = try JSONDecoder().decode(TokenResponse.self, from: data)
TokenStorage.shared.accessToken = token.accessToken
}
}🔌 Step 2: Use It in Your Moya Provider
let provider = MoyaProvider<MyAPI>(plugins: [AuthPlugin()])
provider.request(.getUserProfile) { result in
switch result {
case .success(let response):
print("User profile fetched: \(response)")
case .failure(let error):
print("Request failed: \(error)")
}
}🧠 Quick Note
This plugin setup retries failed requests after the token is refreshed — but only for single-shot requests. If your app fires multiple simultaneous requests during token expiry, this basic queue can help, but for large apps, you may want to move this logic into a service layer or central request manager.
Need help deciding if Moya is right for your project? Here’s a breakdown that might help:
👉 Moya vs Alamofire vs URLSession — Which One Should You Choose?
**Moya vs Alamofire vs URLSession in Swift: Which One Should You Choose and Why?** _⚔️ Confused Between Moya, Alamofire, and URLSession in Swift? Discover the pros, cons, and best use cases for each…_medium.comhttps://medium.com/swift-pal/moya-vs-alamofire-vs-urlsession-in-swift-which-one-should-you-choose-and-why-d2cc3325299c
That wraps up Moya’s approach to token refresh — you now have three complete implementations, one for each major networking tool in Swift. Up next: let’s talk security, testing, and the not-so-obvious things developers tend to overlook.
🔐 Security Best Practices You Shouldn’t Skip
Refreshing tokens is only half the job — how you store and secure them matters just as much. After all, if someone gets access to your tokens, they’ve basically stolen the user’s session key to the kingdom. 🏰
Here’s how to keep things airtight:
🔑 Store Tokens in Keychain, Not UserDefaults
UserDefaults is for preferences, not secrets. Always store access and refresh tokens in the Keychain, which is encrypted and sandboxed per app.
KeychainWrapper.standard.set(token.accessToken, forKey: "access_token")
KeychainWrapper.standard.set(token.refreshToken, forKey: "refresh_token")You can use libraries like KeychainAccess or SwiftKeychainWrapper for simplicity — or Apple’s APIs if you’re feeling brave.
🔒 Use HTTPS (Always. No Excuses.)
Even if you’re sending tokens in headers, make sure your backend enforces HTTPS. Otherwise, you’re handing out credentials on a public megaphone.
🚫 Don’t Retry Forever
If your refresh token fails (expired, revoked, or invalid), don’t keep retrying. Instead, log the user out and prompt them to sign in again. Infinite retries will make your app look broken — or worse, like it’s stuck in a loop of doom 🔁💀
catch URLError.userAuthenticationRequired {
// Redirect to login screen or clear session
}🧠 Optional: Add Expiry Awareness
Instead of waiting for a 401, some apps decode the JWT token to check expiry proactively and refresh ahead of time. It’s not mandatory, but it gives you a little buffer.
🕵️♀️ Obfuscate If Needed
For high-security apps (finance, health, etc.), you might also consider obfuscating token-handling logic, adding biometric checks, or rotating refresh tokens after each use.
Security might feel like overkill until something goes wrong. Build it right, and you’ll thank yourself later (and so will your users).
Next up — let’s talk about concurrency nightmares and how to avoid them when handling multiple token refreshes.
⚠️ Avoiding Token Refresh Chaos (a.k.a. Concurrency Nightmares)
Here’s a fun (read: not fun) scenario — your token expires, and five API calls hit the server at once, each getting a 401. Now your app is panicking and firing five refresh requests at the same time. Suddenly you’re not just refreshing tokens — you’re DDOS-ing your own backend. 😵💫
This is one of the most overlooked problems in token refresh flows — and it can crash your entire user session if not handled properly.
Let’s fix that.
🧯 Use a Flag or Lock to Prevent Multiple Refreshes
You only want one refresh request at a time, no matter how many requests fail. The others should wait until that refresh is done, then retry using the new token.
Here’s the idea in plain terms:
- ✅ If a refresh is already in progress → queue up the failed requests
- ✅ Once refresh finishes → replay those requests
- ❌ Don’t start five simultaneous refreshes
🧠 How We Already Did This
In both the TokenInterceptor (Alamofire) and AuthPlugin (Moya), we used:
- A simple isRefreshing flag
- A retryQueue to collect and retry pending requests after refresh
This pattern ensures safe, single refresh even when multiple requests fail around the same time.
If you’re using URLSession, the same logic applies — wrap your refresh method and request retry logic in an async lock, DispatchSemaphore, or a custom queue to control access.
💡 Bonus: Use an Async Refresh Manager (Optional)
For larger apps, consider writing a singleton TokenRefreshManager that:
- Handles refresh status
- Queues up requests
- Broadcasts completion once the refresh is done
That way, your networking layer doesn’t have to worry about coordination at all.
The point is: don’t let multiple failed requests trigger multiple refreshes. A bit of coordination goes a long way in keeping your app fast, safe, and smooth — even under pressure.
🧪 How to Test Token Expiry and Refresh Logic
Now that we’ve built the logic, let’s make sure it actually works — without waiting for real tokens to expire. Because no one has time to set reminders for “test in 58 minutes when token expires.” 😅
Here’s how you can simulate and test token refresh flows properly:
🧰 1. Use Mock APIs to Simulate 401 Responses
If you control the backend or use tools like Postman Mock Servers, you can return a 401 on demand. That lets you trigger refresh flows in a controlled way.
Alternatively, you can temporarily hardcode a fake 401 response in dev builds.
🧙 2. Intercept and Stub Using URLProtocol (For URLSession)
If you’re using URLSession, you can intercept requests using URLProtocol and fake a 401:
class Mock401Protocol: URLProtocol {
override class func canInit(with request: URLRequest) -> Bool {
return true
}
override func startLoading() {
let response = HTTPURLResponse(url: request.url!, statusCode: 401, httpVersion: nil, headerFields: nil)!
client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
client?.urlProtocolDidFinishLoading(self)
}
override func stopLoading() {}
}To see a complete, real-world usage, check this 3-minute breakdown I wrote:
👉 Swift URLProtocol Explained (for Testing)
**Swift URLProtocol Explained in 3 Minutes (With Real Example for Testing)** _Want to mock network calls in Swift without setting up a mock server or third-party tool?_medium.comhttps://medium.com/swift-pal/swift-urlprotocol-explained-in-3-minutes-with-real-example-for-testing-ed71afe986d6
🧪 3. Write Integration Tests for Refresh Logic
If you’re using a networking layer or wrapper, write integration tests that simulate:
- An expired token scenario (401)
- A successful refresh flow
- A failed refresh → logout path
Make sure these don’t just pass — assert that the original request is retried only once, not multiple times.
💡 Pro Tip
Test how your app behaves when:
- Multiple requests fail at once (does it refresh once?)
- Refresh token itself expires (does it logout gracefully?)
- You lose network during refresh (does it retry or fail fast?)
These are the moments that break user trust. Catch them in testing — not production.
Just like you test UI and business logic, test your token logic like your app’s credibility depends on it — because it kinda does.
✅ Wrapping Up: Handle Token Expiry Like a Pro
Token expiry isn’t a bug — it’s a feature. But how you handle it separates a polished app from one that randomly kicks users out like an impatient bouncer. 🚫👟
Here’s what we covered:
- Why tokens expire (Hint: 🛡️ security)
- How to detect 401 errors and trigger a refresh flow
- Three real-world implementations:
- Manual control with URLSession
- Clean retries with Alamofire’s RequestInterceptor
- Plugin-powered handling with auto-retry in Moya
- How to stay safe (store in Keychain, don’t retry forever)
- How to avoid refresh stampedes when multiple requests fail together
- How to test all of this without pulling an all-nighter waiting for tokens to expire 🕒
Whether you’re building a finance app, a chat app, or just something that loves giving out JWTs like candy, this flow will help make your user experience seamless — even when tokens inevitably go stale.
Want to dive deeper into building clean, testable networking layers? This guide might be your next stop:
👉 URLSession in Swift — Build a Clean and Testable Networking Layer
🎉 Enjoyed this article? Your support means the world to me!
💬 Drop a comment below! I love hearing about your experiences and answering questions
🎬 Subscribe on Youtube and become early subscribers of my channel: https://www.youtube.com/@swift-pal
💼 Let’s connect on LinkedIn for more professional insights: https://www.linkedin.com/in/karan-pal
Happy coding! 🚀
New articles, straight to your inbox.
No spam, no filler — just new writing on iOS, the web, and AI when it ships. Unsubscribe anytime.
Keep reading
Why I'm Rebuilding My Blog From Scratch (and Leaving Medium)
After years of publishing on someone else's platform, I'm moving my writing to a home I actually own. Here's the reasoning, and what I'm building instead.
ReadData Is the Model: The Most Ignored Part of AI
A beginner-friendly guide to why data quality beats model hype.
Read