<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0">
<channel>
<title>Coder Legion - Recent posts tagged oauth</title>
<link>https://coderlegion.com/tag/oauth</link>
<description>Powered by Question2Answer</description>
<item>
<title>AdMob Earnings Dashboard for iOS: OAuth with PKCE, AdMob Reporting API and Offline Caching</title>
<link>https://coderlegion.com/32550/admob-earnings-dashboard-for-ios-oauth-with-pkce-admob-reporting-api-and-offline-caching</link>
<description>&lt;p&gt;&lt;em&gt;Originally published at &lt;a rel=&quot;nofollow&quot; href=&quot;https://bestlatestapps.com/blog/building-an-admob-earnings-dashboard-for-ios/&quot;&gt;https://bestlatestapps.com/blog/building-an-admob-earnings-dashboard-for-ios/&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;&lt;em&gt;Signing in with ASWebAuthenticationSession and PKCE, storing tokens safely in the Keychain, calling networkReport:generate, decoding a streamed report, turning micros into money, why &quot;today&quot; is a Los Angeles day, live earning rates, and caching for offline use&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://bestlatestapps.com/assets/blog/building-an-admob-earnings-dashboard-for-ios/adearnings-pro-icon-512.jpg&quot; alt=&quot;AdEarnings Pro app icon&quot;&gt;&lt;/p&gt;
&lt;p&gt;If you have apps with ads, you know the habit: open the AdMob website on your phone, pinch, zoom, wait for the dashboard, then do it again an hour later. I wanted a native, fast, glanceable view of my AdMob earnings, so I built &lt;strong&gt;AdEarnings Pro&lt;/strong&gt;. It's a dashboard for today's earnings, yesterday's comparison, earning rates per second, minute and hour, weekly averages for earnings and clicks, six months of history and currency conversion. It uses Google Sign-In, and the listing states plainly: &quot;Your AdMob data is not saved, shared, or accessible to us.&quot;&lt;/p&gt;
&lt;p&gt;That promise shapes the whole architecture. There's no server of mine in the middle. The app talks &lt;strong&gt;directly&lt;/strong&gt; to Google's AdMob API using the user's own OAuth token, and everything is cached on the device.&lt;/p&gt;
&lt;p&gt;This article walks through how to build that: OAuth for a native app done properly, the AdMob Reporting API's actual request and response format, and a few surprises from Google's documentation. The biggest is that the report time zone currently supports only &lt;code&gt;America/Los_Angeles&lt;/code&gt;, which changes what &quot;today&quot; means for everyone outside California. Apple APIs are checked against Apple's documentation and Google APIs against Google's. Where I describe my approach rather than the shipped code, I say so.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;1-the-shape-of-the-app-no-middleman&quot;&gt;1. The shape of the app: no middleman&lt;/h3&gt;
&lt;p&gt;The data flow is deliberately short:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;The user signs in with Google and grants a &lt;strong&gt;read-only reporting scope&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;The app stores the refresh token in the &lt;strong&gt;Keychain&lt;/strong&gt;, on the device.&lt;/li&gt;
&lt;li&gt;The app calls &lt;code&gt;admob.googleapis.com&lt;/code&gt; &lt;strong&gt;directly&lt;/strong&gt; with a short-lived access token.&lt;/li&gt;
&lt;li&gt;Results are cached locally for offline viewing.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Google's AdMob API getting-started guide lists two scopes. &lt;code&gt;admob.readonly&lt;/code&gt; lets an app &quot;See all AdMob data&quot;, and Google notes that this &quot;doesn't include sensitive data, such as payments or campaign details&quot;. &lt;code&gt;admob.report&lt;/code&gt; lets it &quot;See ad performance and earnings reports. See publisher ID, timezone, and default currency code.&quot; For an earnings dashboard, &lt;strong&gt;&lt;code&gt;admob.report&lt;/code&gt; is the narrowest scope that works&lt;/strong&gt;, and asking for the minimum is both good privacy and good for consent rates. Google's OAuth guide makes the same point: the fewer scopes you request, the more likely users are to grant them.&lt;/p&gt;
&lt;p&gt;The listing also notes &quot;This app requires access to your existing Google AdMob account&quot;. The API reads accounts; it can't create one.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;2-oauth-for-a-native-app-system-browser-pkce-no-embedded-web-views&quot;&gt;2. OAuth for a native app: system browser, PKCE, no embedded web views&lt;/h3&gt;
&lt;p&gt;Google's OAuth documentation is blunt about embedded web views. Opening the authorization page in &lt;code&gt;WKWebView&lt;/code&gt; produces a &lt;code&gt;disallowed_useragent&lt;/code&gt; error, and Google recommends its Google Sign-In for iOS library or AppAuth instead. Both are built on the same standard flow: the &lt;strong&gt;system browser&lt;/strong&gt; plus &lt;strong&gt;PKCE&lt;/strong&gt; (Proof Key for Code Exchange). Here's that flow with Apple's own &lt;code&gt;ASWebAuthenticationSession&lt;/code&gt; (iOS 12+), so you can see every step:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;import AuthenticationServices
import UIKit
import CryptoKit
import Foundation

struct PKCE {
    let verifier: String
    let challenge: String

    /// Google: verifier is 43–128 chars of [A-Z a-z 0-9 - . _ ~]; S256 challenge = BASE64URL(SHA256(verifier)).
    static func make() -&amp;gt; PKCE {
        var bytes = [UInt8](repeating: 0, count: 32)
        _ = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &amp;amp;bytes)
        let verifier = Data(bytes).base64URLEncoded()                     // 43 chars
        let challenge = Data(SHA256.hash(data: Data(verifier.utf8))).base64URLEncoded()
        return PKCE(verifier: verifier, challenge: challenge)
    }
}

extension Data {
    func base64URLEncoded() -&amp;gt; String {
        base64EncodedString().replacingOccurrences(of: &quot;+&quot;, with: &quot;-&quot;)
            .replacingOccurrences(of: &quot;/&quot;, with: &quot;_&quot;).replacingOccurrences(of: &quot;=&quot;, with: &quot;&quot;)
    }
}

@MainActor
final class GoogleAuth: NSObject, ASWebAuthenticationPresentationContextProviding {
    // An iOS OAuth client in Google Cloud; its redirect uses the reversed client ID as the scheme.
    let clientID = &quot;1234-abc.apps.googleusercontent.com&quot;
    var redirectScheme: String { &quot;com.googleusercontent.apps.1234-abc&quot; }
    var redirectURI: String { &quot;\(redirectScheme):/oauth2redirect&quot; }

    func signIn() async throws -&amp;gt; (code: String, pkce: PKCE) {
        let pkce = PKCE.make()
        let state = UUID().uuidString
        var url = URLComponents(string: &quot;https://accounts.google.com/o/oauth2/v2/auth&quot;)!
        url.queryItems = [
            .init(name: &quot;client_id&quot;, value: clientID),
            .init(name: &quot;redirect_uri&quot;, value: redirectURI),
            .init(name: &quot;response_type&quot;, value: &quot;code&quot;),
            .init(name: &quot;scope&quot;, value: &quot;https://www.googleapis.com/auth/admob.report&quot;),
            .init(name: &quot;code_challenge&quot;, value: pkce.challenge),
            .init(name: &quot;code_challenge_method&quot;, value: &quot;S256&quot;),
            .init(name: &quot;state&quot;, value: state)
        ]

        let callback: URL = try await withCheckedThrowingContinuation { cont in
            let session = ASWebAuthenticationSession(url: url.url!, callbackURLScheme: redirectScheme) { url, error in
                if let url { cont.resume(returning: url) } else { cont.resume(throwing: error ?? URLError(.cancelled)) }
            }
            session.presentationContextProvider = self
            session.prefersEphemeralWebBrowserSession = false   // reuse an existing Google session if there is one
            session.start()
        }

        let items = URLComponents(url: callback, resolvingAgainstBaseURL: false)?.queryItems ?? []
        guard items.first(where: { $0.name == &quot;state&quot; })?.value == state,       // CSRF check
              let code = items.first(where: { $0.name == &quot;code&quot; })?.value else { throw URLError(.userAuthenticationRequired) }
        return (code, pkce)
    }

    func presentationAnchor(for session: ASWebAuthenticationSession) -&amp;gt; ASPresentationAnchor {
        UIApplication.shared.connectedScenes.compactMap { ($0 as? UIWindowScene)?.keyWindow }.first ?? ASPresentationAnchor()
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Every parameter here comes from Google's OAuth documentation for installed apps:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;The authorization endpoint&lt;/strong&gt; is &lt;code&gt;accounts.google.com/o/oauth2/v2/auth&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;response_type=code&lt;/code&gt;&lt;/strong&gt; is required.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The challenge is S256&lt;/strong&gt;, which Google recommends.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The verifier is 43 to 128 characters.&lt;/strong&gt; Thirty-two random bytes, base64url-encoded, gives exactly 43.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;state&lt;/code&gt;&lt;/strong&gt; protects against cross-site request forgery. Google calls it out as important, and the code checks it.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;On iOS 17.4 and later, &lt;code&gt;ASWebAuthenticationSession&lt;/code&gt; also accepts a &lt;code&gt;Callback&lt;/code&gt; object (&lt;code&gt;ASWebAuthenticationSession.Callback&lt;/code&gt;). SwiftUI has a &lt;code&gt;webAuthenticationSession&lt;/code&gt; environment value from iOS 16.4. Either is a tidier wrapper around the same flow.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;3-exchanging-the-code-and-keeping-tokens-safe&quot;&gt;3. Exchanging the code and keeping tokens safe&lt;/h3&gt;
&lt;p&gt;Next, swap the code for tokens at &lt;code&gt;oauth2.googleapis.com/token&lt;/code&gt;, sending the PKCE &lt;strong&gt;verifier&lt;/strong&gt; (not the challenge). Google's docs note that &lt;code&gt;client_secret&lt;/code&gt; doesn't apply to iOS clients:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;struct TokenResponse: Decodable {
    let access_token: String
    let expires_in: Int
    let refresh_token: String?
    let scope: String
}

extension GoogleAuth {
    func exchange(code: String, pkce: PKCE) async throws -&amp;gt; TokenResponse {
        try await tokenRequest([
            &quot;client_id&quot;: clientID, &quot;code&quot;: code, &quot;code_verifier&quot;: pkce.verifier,
            &quot;grant_type&quot;: &quot;authorization_code&quot;, &quot;redirect_uri&quot;: redirectURI
        ])
    }

    func refresh(using refreshToken: String) async throws -&amp;gt; TokenResponse {
        try await tokenRequest([&quot;client_id&quot;: clientID, &quot;grant_type&quot;: &quot;refresh_token&quot;, &quot;refresh_token&quot;: refreshToken])
    }

    private func tokenRequest(_ form: [String: String]) async throws -&amp;gt; TokenResponse {
        var request = URLRequest(url: URL(string: &quot;https://oauth2.googleapis.com/token&quot;)!)
        request.httpMethod = &quot;POST&quot;
        request.setValue(&quot;application/x-www-form-urlencoded&quot;, forHTTPHeaderField: &quot;Content-Type&quot;)
        var body = URLComponents(); body.queryItems = form.map { URLQueryItem(name: $0.key, value: $0.value) }
        request.httpBody = body.percentEncodedQuery?.data(using: .utf8)
        let (data, _) = try await URLSession.shared.data(for: request)
        return try JSONDecoder().decode(TokenResponse.self, from: data)
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Then store the &lt;strong&gt;refresh token&lt;/strong&gt; in the Keychain, never in &lt;code&gt;UserDefaults&lt;/code&gt;, and mark it device-only:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;import Security

enum TokenVault {
    static let account = &quot;admob.refresh&quot;

    static func save(_ token: String) {
        let base: [String: Any] = [kSecClass as String: kSecClassGenericPassword,
                                   kSecAttrAccount as String: account]
        SecItemDelete(base as CFDictionary)
        var item = base
        item[kSecValueData as String] = Data(token.utf8)
        item[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
        SecItemAdd(item as CFDictionary, nil)
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;AfterFirstUnlockThisDeviceOnly&lt;/code&gt; lets a widget or a background refresh read the token after the first unlock, while keeping it out of backups and off other devices. Two of Google's notes are worth building in. First, check the returned &lt;code&gt;scope&lt;/code&gt;: users can grant less than you asked for, and the app should explain what's missing. Second, there are limits on how many refresh tokens are issued per client and user, so &lt;strong&gt;keep and reuse&lt;/strong&gt; the refresh token rather than signing in again each time.&lt;/p&gt;
&lt;p&gt;&quot;Revoke consent anytime&quot; maps to two things. The user can remove access from their Google Account settings, and the app can call &lt;code&gt;https://oauth2.googleapis.com/revoke?token=…&lt;/code&gt; on sign-out and then delete the Keychain item.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;4-asking-admob-for-a-report&quot;&gt;4. Asking AdMob for a report&lt;/h3&gt;
&lt;p&gt;The AdMob API's core endpoint is &lt;code&gt;accounts.networkReport.generate&lt;/code&gt;, a &lt;code&gt;POST&lt;/code&gt; to &lt;code&gt;https://admob.googleapis.com/v1/accounts/{publisherId}/networkReport:generate&lt;/code&gt; with a &lt;code&gt;reportSpec&lt;/code&gt;. Here's a request for daily earnings and clicks over the last seven days:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;struct ReportDate: Codable { let year: Int, month: Int, day: Int }

struct NetworkReportRequest: Encodable {
    struct Spec: Encodable {
        struct Range: Encodable { let startDate: ReportDate; let endDate: ReportDate }
        struct Localization: Encodable { let currencyCode: String; let languageCode: String }
        let dateRange: Range
        let dimensions: [String]
        let metrics: [String]
        let localizationSettings: Localization
        let timeZone: String
    }
    let reportSpec: Spec
}

func weeklyRequest(endingOn end: ReportDate, start: ReportDate, currency: String) -&amp;gt; NetworkReportRequest {
    .init(reportSpec: .init(
        dateRange: .init(startDate: start, endDate: end),
        dimensions: [&quot;DATE&quot;],
        metrics: [&quot;ESTIMATED_EARNINGS&quot;, &quot;CLICKS&quot;, &quot;IMPRESSIONS&quot;],
        localizationSettings: .init(currencyCode: currency, languageCode: &quot;en-US&quot;),
        timeZone: &quot;America/Los_Angeles&quot;
    ))
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Three facts from Google's reference page shape this request:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;ESTIMATED_EARNINGS&lt;/code&gt; is in micros.&lt;/strong&gt; &quot;$6.50 would be represented as 6500000.&quot; Never put that number in a &lt;code&gt;Double&lt;/code&gt; and divide casually; use &lt;code&gt;Decimal&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Currency comes from &lt;code&gt;localizationSettings.currencyCode&lt;/code&gt;.&lt;/strong&gt; Google says the currency unit of the earnings metrics is &quot;determined by the localization setting for currency&quot;. The approach I'd use for &quot;View earnings in your preferred currency&quot; is to ask AdMob for that currency directly, so the figures are AdMob's own.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The time zone currently has only one supported value.&lt;/strong&gt; Google: &quot;Warning: The 'America/Los_Angeles' is the only supported value at the moment.&quot; Section 6 is about what that means.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h3 id=&quot;5-decoding-a-streamed-report&quot;&gt;5. Decoding a streamed report&lt;/h3&gt;
&lt;p&gt;The response isn't a simple object. It's &quot;the result of a server-side streaming RPC&quot;: a JSON &lt;strong&gt;array&lt;/strong&gt; whose first element has a &lt;code&gt;header&lt;/code&gt;, then one &lt;code&gt;row&lt;/code&gt; per result, then a &lt;code&gt;footer&lt;/code&gt;. Each element has exactly one of those keys. So decode each element as an enum-like struct:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;struct ReportChunk: Decodable {
    struct Row: Decodable {
        struct DimensionValue: Decodable { let value: String }
        struct MetricValue: Decodable { let microsValue: String?; let integerValue: String?; let doubleValue: Double? }
        let dimensionValues: [String: DimensionValue]
        let metricValues: [String: MetricValue]
    }
    let header: [String: AnyDecodable]?      // dateRange, localizationSettings
    let row: Row?
    let footer: [String: AnyDecodable]?      // matchingRowCount, warnings
}

struct AnyDecodable: Decodable { init(from decoder: Decoder) throws {} }   // we don't need header details here

struct DailyEarnings: Codable, Identifiable {
    var id: String { day }
    let day: String          // &quot;20261022&quot;
    let earnings: Decimal    // in report currency
    let clicks: Int
}

func parse(_ data: Data) throws -&amp;gt; [DailyEarnings] {
    try JSONDecoder().decode([ReportChunk].self, from: data).compactMap(\.row).map { row in
        let micros = Decimal(string: row.metricValues[&quot;ESTIMATED_EARNINGS&quot;]?.microsValue ?? &quot;0&quot;) ?? 0
        return DailyEarnings(day: row.dimensionValues[&quot;DATE&quot;]?.value ?? &quot;&quot;,
                             earnings: micros / 1_000_000,
                             clicks: Int(row.metricValues[&quot;CLICKS&quot;]?.integerValue ?? &quot;0&quot;) ?? 0)
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Google's example response shows &lt;code&gt;&quot;microsValue&quot;: 6500000&lt;/code&gt; as a bare number. The API's JSON mapping for 64-bit integers often uses strings, so I'd decode both forms in production (a small custom &lt;code&gt;init(from:)&lt;/code&gt;). The &lt;code&gt;DATE&lt;/code&gt; dimension value is &lt;code&gt;YYYYMMDD&lt;/code&gt;, as Google's reference describes.&lt;/p&gt;
&lt;p&gt;Displaying money is then &lt;code&gt;earnings.formatted(.currency(code: currency))&lt;/code&gt;. Don't hand-roll formats, because symbol placement and grouping vary by locale.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;6-why-today-is-a-los-angeles-day&quot;&gt;6. Why &quot;today&quot; is a Los Angeles day&lt;/h3&gt;
&lt;p&gt;Here's the subtle part. If reports can only be in &lt;code&gt;America/Los_Angeles&lt;/code&gt;, then AdMob's &quot;today&quot; starts at &lt;strong&gt;midnight Pacific time&lt;/strong&gt;. For me in India, that's 12:30 or 1:30 in the afternoon, depending on US daylight saving. A dashboard that labels the latest row &quot;Today&quot; without explanation will confuse anyone outside the Pacific time zone.&lt;/p&gt;
&lt;p&gt;The approach I'd use is to compute &quot;today&quot; in the report's calendar and say so in the UI:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;struct ReportCalendar {
    static let zone = TimeZone(identifier: &quot;America/Los_Angeles&quot;)!
    static var calendar: Calendar { var c = Calendar(identifier: .gregorian); c.timeZone = zone; return c }

    static func reportDate(for date: Date = .now) -&amp;gt; ReportDate {
        let c = calendar.dateComponents([.year, .month, .day], from: date)
        return ReportDate(year: c.year!, month: c.month!, day: c.day!)
    }

    /// Seconds elapsed in the current *report* day; the denominator for live rates.
    static func secondsIntoReportDay(now: Date = .now) -&amp;gt; TimeInterval {
        now.timeIntervalSince(calendar.startOfDay(for: now))
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;strong&gt;live earning rates&lt;/strong&gt; in the listing (&quot;per second, minute, and hour&quot;) follow naturally. Today's earnings divided by seconds into the report day gives an average rate so far:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;func rates(todayEarnings: Decimal, now: Date = .now) -&amp;gt; (perSecond: Decimal, perMinute: Decimal, perHour: Decimal) {
    let seconds = Decimal(max(ReportCalendar.secondsIntoReportDay(now: now), 60))   // avoid tiny denominators just after midnight
    let perSecond = todayEarnings / seconds
    return (perSecond, perSecond * 60, perSecond * 3600)
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Label it honestly, as &quot;average so far today&quot;. Ad revenue isn't evenly spread over a day, and AdMob's figures are &lt;em&gt;estimated&lt;/em&gt; earnings that can be revised.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;7-pull-to-refresh-offline-cache-and-the-weekly-view&quot;&gt;7. Pull to refresh, offline cache and the weekly view&lt;/h3&gt;
&lt;p&gt;&quot;Pull-to-refresh for fresh data&quot; is one modifier, &lt;code&gt;refreshable&lt;/code&gt; (iOS 15+). &quot;Offline-ready with cached data&quot; means saving the last good response, with a timestamp, and showing it whenever the network fails:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;import SwiftUI

@MainActor
final class Dashboard: ObservableObject {
    @Published private(set) var days: [DailyEarnings] = []
    @Published private(set) var updatedAt: Date?
    private let cacheURL = URL.cachesDirectory.appending(path: &quot;report-week.json&quot;)

    init() {
        if let data = try? Data(contentsOf: cacheURL),
           let cached = try? JSONDecoder().decode(Cached.self, from: data) {
            days = cached.days; updatedAt = cached.at                   // instant first paint, even offline
        }
    }

    func refresh(using api: AdMobAPI) async {
        do {
            let fresh = try await api.lastSevenDays()
            days = fresh; updatedAt = .now
            try? JSONEncoder().encode(Cached(days: fresh, at: .now)).write(to: cacheURL, options: .atomic)
        } catch {
            // Keep showing the cache; the &quot;Updated 2 h ago&quot; label tells the user it's stale.
        }
    }

    var weeklyAverage: Decimal {
        days.isEmpty ? 0 : days.map(\.earnings).reduce(0, +) / Decimal(days.count)
    }

    private struct Cached: Codable { let days: [DailyEarnings]; let at: Date }
}

struct DashboardView: View {
    @StateObject private var model = Dashboard()
    let api: AdMobAPI
    var body: some View {
        List {
            if let updatedAt = model.updatedAt {
                Text(&quot;Updated \(updatedAt, format: .relative(presentation: .named))&quot;).font(.caption)
            }
            ForEach(model.days.reversed()) { day in
                LabeledContent(day.day, value: day.earnings, format: .currency(code: &quot;USD&quot;))
            }
        }
        .refreshable { await model.refresh(using: api) }
        .task { await model.refresh(using: api) }
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Putting the cache in &lt;strong&gt;Caches&lt;/strong&gt; is deliberate. It's disposable, it's re-fetchable, and it holds only aggregated numbers from the user's own account on the user's own device. That fits the listing's privacy statement. (&lt;code&gt;AdMobAPI&lt;/code&gt; here is the small wrapper around section 4's request, with a valid access token.)&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;8-real-world-example-adearnings-pro&quot;&gt;8. Real-world example: AdEarnings Pro&lt;/h3&gt;
&lt;p&gt;This walkthrough comes from building &lt;strong&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://apps.apple.com/us/app/adearnings-pro/id6754509445&quot;&gt;AdEarnings Pro&lt;/a&gt;&lt;/strong&gt;. The code above is the approach I'd use, written for this article, not a copy of the app's source.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://bestlatestapps.com/assets/blog/building-an-admob-earnings-dashboard-for-ios/adearnings-pro-screenshot-1.jpg&quot; alt=&quot;AdEarnings Pro App Store screenshot 1&quot;&gt;&lt;/p&gt;
&lt;p&gt;At the time of writing, its App Store listing shows:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Free&lt;/strong&gt;, in &lt;strong&gt;Developer Tools&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;First released in &lt;strong&gt;November 2025&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;No ratings yet in the US store&lt;/li&gt;
&lt;li&gt;Supports &lt;strong&gt;iOS 17.6 and later&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;How the listing maps to this article:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;What the listing says &lt;/th&gt;&lt;th&gt;Where it's covered&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;&quot;Secure Google Sign-In authentication&quot; / &quot;Google OAuth authentication is required to access AdMob API services&quot; &lt;/td&gt;&lt;td&gt;System-browser OAuth with PKCE, tokens in the Keychain (sections 2–3)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&quot;Your AdMob data is not saved, shared, or accessible to us&quot; &lt;/td&gt;&lt;td&gt;Direct device-to-Google calls, no middleman (section 1)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&quot;View today's earnings with live updates. Compare yesterday's performance.&quot; &lt;/td&gt;&lt;td&gt;&lt;code&gt;networkReport:generate&lt;/code&gt; with the &lt;code&gt;DATE&lt;/code&gt; dimension (sections 4–5)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&quot;Live earning rates per second, minute, and hour&quot; &lt;/td&gt;&lt;td&gt;Rates over the Pacific report day (section 6)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&quot;Weekly average trends for earnings and clicks&quot; &lt;/td&gt;&lt;td&gt;&lt;code&gt;ESTIMATED_EARNINGS&lt;/code&gt; and &lt;code&gt;CLICKS&lt;/code&gt; (sections 4 and 7)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&quot;Convert earnings to any currency…&quot; &lt;/td&gt;&lt;td&gt;&lt;code&gt;localizationSettings.currencyCode&lt;/code&gt; (section 4)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&quot;Pull-to-refresh for fresh data&quot; / &quot;Offline-ready with cached data&quot; &lt;/td&gt;&lt;td&gt;&lt;code&gt;refreshable&lt;/code&gt; and a timestamped cache (section 7)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&quot;Revoke consent anytime&quot; &lt;/td&gt;&lt;td&gt;Google Account settings and the revoke endpoint (section 3)&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;&lt;img src=&quot;https://bestlatestapps.com/assets/blog/building-an-admob-earnings-dashboard-for-ios/adearnings-pro-screenshot-2.jpg&quot; alt=&quot;AdEarnings Pro App Store screenshot 2&quot;&gt;&lt;/p&gt;
&lt;p&gt;The listing also mentions unpaid earnings, last payment history and GDPR consent management. Those are outside this article's scope.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;9-before-you-ship-an-oauth-powered-dashboard-a-checklist&quot;&gt;9. Before you ship an OAuth-powered dashboard: a checklist&lt;/h3&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Use the narrowest scope that works&lt;/strong&gt;, and check the granted &lt;code&gt;scope&lt;/code&gt; in the token response.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Use the system browser, PKCE (S256) and a &lt;code&gt;state&lt;/code&gt; check.&lt;/strong&gt; No &lt;code&gt;WKWebView&lt;/code&gt; for sign-in.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Keep the refresh token in the Keychain&lt;/strong&gt;, device-only, and reuse it.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Sign-out revokes the token&lt;/strong&gt; and clears the Keychain and caches.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Handle money as &lt;code&gt;Decimal&lt;/code&gt; from micros&lt;/strong&gt;, formatted with the currency's own format.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Make the report time zone explicit.&lt;/strong&gt; &quot;Today&quot; is a Pacific day, and the UI says so.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Label rates and estimates honestly&lt;/strong&gt;: &quot;average so far&quot; and &quot;estimated&quot;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Show cached data instantly&lt;/strong&gt;, with a visible &quot;Updated…&quot; time.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Complete Google's OAuth app verification&lt;/strong&gt; if your scopes need it before public release. Google's docs describe an &quot;unverified app&quot; screen otherwise.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;No third-party analytics touches the earnings data.&lt;/strong&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;hr&gt;
&lt;h3 id=&quot;wrapping-up&quot;&gt;Wrapping up&lt;/h3&gt;
&lt;p&gt;An earnings dashboard looks like a chart, but it's mostly about trust. Sign in the way Google and Apple both recommend, ask for the least access, keep tokens on the device, talk to the API directly, and be honest about what the numbers mean, including the surprising fact that AdMob's &quot;today&quot; is a Los Angeles day. Get those right, and a glance at your phone replaces a dozen trips to a desktop website.&lt;/p&gt;
&lt;p&gt;Try the app, or explore my other work:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;AdEarnings Pro (iOS):&lt;/strong&gt; &lt;a rel=&quot;nofollow&quot; href=&quot;https://apps.apple.com/us/app/adearnings-pro/id6754509445&quot;&gt;https://apps.apple.com/us/app/adearnings-pro/id6754509445&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;All of my free iOS apps:&lt;/strong&gt; &lt;a rel=&quot;nofollow&quot; href=&quot;https://apps.apple.com/us/developer/alok-singh/id1508795536&quot;&gt;https://apps.apple.com/us/developer/alok-singh/id1508795536&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;My portfolio:&lt;/strong&gt; &lt;a rel=&quot;nofollow&quot; href=&quot;https://bestlatestapps.com/alok/&quot;&gt;https://bestlatestapps.com/alok/&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;What's the one metric you check most in your ad dashboard? Tell me in the comments.&lt;/p&gt;
&lt;p&gt;&lt;em&gt;— Alok Kumar Singh&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;&lt;em&gt;Originally published at &lt;a rel=&quot;nofollow&quot; href=&quot;https://bestlatestapps.com/blog/building-an-admob-earnings-dashboard-for-ios/&quot;&gt;https://bestlatestapps.com/blog/building-an-admob-earnings-dashboard-for-ios/&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/32550/admob-earnings-dashboard-for-ios-oauth-with-pkce-admob-reporting-api-and-offline-caching</guid>
<pubDate>Mon, 26 Oct 2026 12:42:23 +0000</pubDate>
</item>
<item>
<title>OAuth Is Not “Login.” I Found That Out the Hard Way in Production</title>
<link>https://coderlegion.com/31422/oauth-is-not-login-i-found-that-out-the-hard-way-in-production</link>
<description>&lt;p&gt;Most people, including a lot of engineers, think OAuth means “the button that says Sign in with Google.” That’s not wrong exactly, but it’s like saying a car is “the thing with a steering wheel.” Technically true, misses basically everything that matters.&lt;/p&gt;
&lt;p&gt;I want to actually walk through what OAuth is, why it exists, what it costs you, and the specific way it bit me in production, because the bug taught me more about OAuth than any tutorial did.&lt;/p&gt;
&lt;p&gt;If I use a term that sounds like engineer-speak, I’ll flag it like this:&lt;/p&gt;
&lt;p&gt;🚨 Jaggy jaggy alert: here’s the jargon, here’s what it actually means in plain English.&lt;/p&gt;
&lt;p&gt;Let’s go.&lt;/p&gt;
&lt;p&gt;What OAuth actually is&lt;br&gt;
OAuth is not a login system. OAuth is a permission system. It answers one question: “can this app do this specific thing on my behalf, without me handing it my password?”&lt;/p&gt;
&lt;p&gt;That’s it. That’s the whole idea.&lt;/p&gt;
&lt;p&gt;Before OAuth, if you wanted some third party app to, say, post to your Twitter or read your Gmail contacts, you had exactly one option: give that app your actual Twitter or Gmail password. The app would log in as you, directly, using your real credentials. Which means that random app now has full access to your account forever, until you go change your password, and even then you’re trusting a third party company stored your password responsibly in the first place.&lt;/p&gt;
&lt;p&gt;OAuth replaces that with a system where you never hand your password to the third party app at all. Instead, you briefly go to Google (or whoever), log in there, and Google hands the app a token, a kind of limited, revocable permission slip, instead of your actual credentials.&lt;/p&gt;
&lt;p&gt;🚨 Jaggy jaggy alert: token. Think of it as a hotel key card instead of your house key. It opens specific doors, for a specific amount of time, and the hotel can deactivate it instantly without you having to change your actual house locks.&lt;/p&gt;
&lt;p&gt;Could the internet work without OAuth&lt;br&gt;
Technically, yes. Plenty of early 2000s apps worked exactly how I described above, you just gave them your password. It’s not that the internet collapses without OAuth.&lt;/p&gt;
&lt;p&gt;What actually happens without it is worse in a slower, quieter way. Every app you connect to anything ends up holding a copy of your real password. A breach at any one of those apps means your actual account password leaks, not just some limited token. You can’t selectively revoke access (“let this app see my calendar but not send emails as me”) because the app either has your full password or it has nothing. And you can’t tell your bank, your email provider, or your social accounts “trust these five apps, not that one,” because from their point of view every connected app looks identical: someone logged in with your password.&lt;/p&gt;
&lt;p&gt;So yes, the world could work without OAuth. It would just be a world where a breach anywhere is a breach everywhere, and “revoke access” isn’t really a button that exists.&lt;/p&gt;
&lt;p&gt;A quick word on OAuth 1.0, the version before this one&lt;br&gt;
Before the OAuth everyone actually uses today, there was OAuth 1.0, back around 2007. Same basic idea, token instead of password, but the mechanics were painful. Every single request had to be cryptographically signed by the app using a shared secret, a process involving HMAC-SHA1 and exact timestamps, before the server would trust it.&lt;/p&gt;
&lt;p&gt;🚨 Jaggy jaggy alert: cryptographic. Just means “based on math that’s specifically designed to be really, really hard to fake or reverse without the right secret ingredient.” When something is described as cryptographically secure, breaking it isn’t impossible, it’s just so expensive and slow that nobody sane bothers trying.&lt;/p&gt;
&lt;p&gt;🚨 Jaggy jaggy alert: signature (signing). Not a handwritten signature. A short code generated from the actual contents of a message plus a secret value, proving two things at once: this message wasn’t altered in transit, and it genuinely came from whoever holds that secret. Change one character of the message and the signature stops matching, which is exactly how tampering gets caught.&lt;/p&gt;
&lt;p&gt;🚨 Jaggy jaggy alert: secret. A private value, basically a password, known only to the two parties who are supposed to trust each other. Whoever holds the secret can prove they are who they claim to be. Whoever doesn’t, can’t fake it convincingly. The whole system falls apart the moment that “secret” stops actually being secret.&lt;/p&gt;
&lt;p&gt;🚨 Jaggy jaggy alert: HMAC-SHA1 signing. A way of mathematically stamping each request so the server can verify nothing was tampered with in transit, instead of just trusting an encrypted connection to do that job. You don’t need the math, just know it meant extra, fiddly cryptography homework on every single request.&lt;/p&gt;
&lt;p&gt;In practice this meant developers spent real time fighting signature mismatches and clock sync issues instead of building their actual app. It worked, but it was miserable, and it still assumed every app had somewhere safe to keep a permanent secret. OAuth 2.0 showed up in 2012 and basically said, let’s just lean on HTTPS to protect the data in transit like everyone already does for everything else, and drop the per-request signing entirely.&lt;/p&gt;
&lt;p&gt;🚨 Jaggy jaggy alert: HTTPS. The padlock in your browser’s address bar. It encrypts everything traveling between your browser and the server, so anyone snooping on the connection, a nosy coffee shop WiFi for instance, sees scrambled nonsense instead of your actual data. OAuth 2.0 leans on this instead of signing every request itself, which is most of why it’s so much simpler than OAuth 1.0.&lt;/p&gt;
&lt;p&gt;Simpler for developers, which is exactly why OAuth 2.0 is the one that actually won and is what the rest of this article has been describing the whole time.&lt;/p&gt;
&lt;p&gt;But that “every app has somewhere safe to keep a secret” assumption didn’t go away, it just got quietly inherited by OAuth 2.0. And a few years later, that assumption stopped being true for a huge chunk of apps. More on that in a minute.&lt;/p&gt;
&lt;p&gt;How it actually works, the short version&lt;br&gt;
Here’s the flow in plain English, using “Sign in with Google” as the example:&lt;/p&gt;
&lt;p&gt;You click the button. The app sends you over to Google, not the other way around, you never type your Google password into the third party app’s own form. At Google, you log in (or you’re already logged in) and Google shows you a screen saying “This app wants to see your email and profile picture, allow it?” You say yes. Google then redirects you back to the app with a short-lived code. The app takes that code and, behind the scenes, trades it with Google for an actual token. From then on, the app uses that token to ask Google for the specific things you approved, nothing more.&lt;/p&gt;
&lt;p&gt;🚨 Jaggy jaggy alert: scope. A scope is the specific permission being requested, like “read your email address” or “post on your behalf.” Apps are supposed to ask for the narrowest scope that does the job, though in practice a lot of apps ask for way more than they need, because broader scopes are easier to code against and nobody’s really checking.&lt;/p&gt;
&lt;p&gt;Download the Medium app&lt;br&gt;
🚨 Jaggy jaggy alert: redirect URI. This is the exact web address Google is allowed to send you back to after login. It’s locked in advance when the app registers with Google, specifically so an attacker can’t trick Google into sending your freshly issued code to some address they control instead.&lt;/p&gt;
&lt;p&gt;That whole flow above quietly assumes one more thing: that the app trading the code for a token is a proper backend server, one that can hold onto a permanent, secret password of its own (a “client secret”) to prove to Google “yes, this request really is from the real app.” Which brings us to a problem nobody really had to think about back in 2012.&lt;/p&gt;
&lt;p&gt;The problem nobody saw coming, apps that can’t keep a secret&lt;br&gt;
Fast forward a few years and suddenly half the apps in the world are single-page apps running entirely in the browser, or mobile apps sitting on someone’s phone. A SPA’s entire codebase gets shipped straight to the user, anyone can pop open dev tools and read it line by line. A mobile app’s installer can be decompiled by anyone with a weekend and mild curiosity. There is nowhere to actually hide a secret in either case. Whatever you bake in, someone can pull back out, which is a uniquely bad punchline for something literally called a “client secret.”&lt;/p&gt;
&lt;p&gt;🚨 Jaggy jaggy alert: client secret. A password-like value, known only to the real backend and Google, used to prove a token request is genuinely coming from the real app and not an imposter. Fine when there’s a server to keep it locked up. Useless the moment the “app” is just JavaScript running on a stranger’s laptop.&lt;/p&gt;
&lt;p&gt;The fix is called PKCE, Proof Key for Code Exchange, and it’s pronounced “pixie,” which might be the single most delightful name in all of security engineering.&lt;/p&gt;
&lt;p&gt;🚨 Jaggy jaggy alert: PKCE. Instead of a long-lived secret baked into the app, the app invents a random, one-time value right before login starts, the code verifier, and sends over a hashed version of it, the code challenge, when kicking off the flow. At the very end, when trading the code for a token, it has to produce that original, un-hashed value again. If someone intercepts the authorization code mid-flow, it’s useless to them, they don’t have the random value that generated it, and they can’t fake having it either.&lt;/p&gt;
&lt;p&gt;🚨 Jaggy jaggy alert: hashing. A one-way math function that turns any input into a fixed-length scrambled output, and crucially, you can’t run it backwards to get the original input out again. Same input always produces the same output, which is exactly what makes it useful here, the app can prove “I know the original value” without ever saying the original value out loud until the very last step.&lt;/p&gt;
&lt;p&gt;No permanent secret to leak, because there was never a permanent secret to begin with, just a fresh, disposable value that self-destructs after one use. Genuinely a cleaner idea than the thing it replaced, and these days it’s recommended for basically every app, server backed or not.&lt;/p&gt;
&lt;p&gt;The trade-off nobody puts on the slide&lt;br&gt;
Here’s what the “just add OAuth” tutorials gloss over: you’re trading simplicity for control, and that trade costs real engineering effort.&lt;/p&gt;
&lt;p&gt;With a plain username and password system, you own the entire flow. One table, one hashing function, done. With OAuth, you now depend on a third party’s uptime, their API changing underneath you, tokens that expire and need refreshing, and an entire second system, your own backend, that has to track who’s logged in even though the actual login happened somewhere else entirely.&lt;/p&gt;
&lt;p&gt;🚨 Jaggy jaggy alert: refresh token. Access tokens are deliberately short-lived, often an hour or less, so if one leaks, the damage window is small. A refresh token is a longer-lived, more sensitive token your backend holds onto quietly, so it can go get a fresh access token without making the user log in again every hour. Lose control of a refresh token and you’ve basically lost control of the session, so these need to be stored far more carefully than access tokens.&lt;/p&gt;
&lt;p&gt;That’s the actual trade-off. You get: no passwords for you to store or leak, users can revoke access with one click from Google’s side, scoped and limited permissions, and single sign-on convenience. You pay for it with: more moving parts, a dependency on an external provider, token lifecycle management, and a genuinely larger surface area for things to quietly go wrong.&lt;/p&gt;
&lt;p&gt;How I actually use this in production&lt;br&gt;
On one platform I worked on, we had Google OAuth as a social login option sitting alongside our own email and password system, with our own JWT issued after either path succeeded.&lt;/p&gt;
&lt;p&gt;🚨 Jaggy jaggy alert: JWT. Short for JSON Web Token. This is different from an OAuth access token, even though people use the words interchangeably all the time. A JWT is a signed, self-contained token your own backend creates, that your own backend can verify without a database lookup, basically a sealed, tamper-proof note saying “this is user 482, and I, the server, vouch for that.” OAuth tokens come from Google. JWTs, in this setup, come from us.&lt;/p&gt;
&lt;p&gt;That distinction, two different kinds of token doing two different jobs, is exactly where things went wrong for me once.&lt;/p&gt;
&lt;p&gt;What actually goes wrong: my real production bug&lt;br&gt;
We had a production authentication bug where users were randomly getting logged out, or logged in as the wrong state, with no clear pattern. Intermittent, hard to reproduce locally, the worst kind of bug.&lt;/p&gt;
&lt;p&gt;The root cause, once I actually traced it through production logs, was almost embarrassingly simple: the OAuth token coming back from the social login flow and our own JWT were both being stored under the same cookie key. Whichever one got written last silently overwrote the other. So depending on timing, a user’s session cookie might end up holding Google’s OAuth token instead of our own JWT, or vice versa, and the backend would try to verify it as the wrong type of token entirely, fail, and drop the session.&lt;/p&gt;
&lt;p&gt;Nobody designed it that way on purpose. It happened because OAuth tokens and JWTs look similar enough, both are just strings you shove in a cookie, that it’s easy to treat them as interchangeable “auth stuff” instead of two distinct tokens with two distinct lifecycles and two distinct purposes. The fix was straightforward once I actually understood the distinction: give each token its own, clearly named cookie key, so they could never collide again.&lt;/p&gt;
&lt;p&gt;The real lesson wasn’t the one line fix. It was that OAuth tokens and your own session tokens are not the same thing wearing a different hat, and treating them as the same thing is exactly the kind of assumption that only breaks in production, under real timing and real concurrent requests, never in your local dev environment where everything happens conveniently one at a time.&lt;/p&gt;
&lt;p&gt;Other pitfalls worth knowing before you hit them&lt;br&gt;
A few more ways this goes sideways, things I’ve seen or would actively watch for:&lt;/p&gt;
&lt;p&gt;Storing tokens in localStorage instead of an httpOnly cookie, which means any cross-site scripting vulnerability on your site can just read the token straight out and walk away with the user's session.&lt;/p&gt;
&lt;p&gt;🚨 Jaggy jaggy alert: httpOnly cookie. A cookie flagged so that JavaScript running in the browser can’t read it at all, only the browser itself can send it along with requests. It’s a deliberate wall against exactly the kind of theft I just described.&lt;/p&gt;
&lt;p&gt;Mismatched redirect URIs between what’s registered and what the app actually sends, which just breaks login outright and is a surprisingly common first deploy bug when you move from a local dev URL to a real production domain and forget to register the new one.&lt;/p&gt;
&lt;p&gt;Asking for way more scopes than you need, which makes users nervous at the consent screen and gives you a bigger blast radius if your token ever does leak.&lt;/p&gt;
&lt;p&gt;Treating “the user has an OAuth token” as “the user is logged in” without actually verifying who they are. OAuth by itself is about authorization, what you’re allowed to do, not identity. Actual login on top of OAuth is a separate layer called OpenID Connect, and conflating the two is a genuinely common source of security bugs.&lt;/p&gt;
&lt;p&gt;So, do you actually need it&lt;br&gt;
If you’re building something small and internal, OAuth is probably overkill, a simple username and password system with properly hashed passwords is less to maintain and less to get wrong. OAuth earns its complexity the moment you want users to log in with an identity they already have elsewhere, or you want to act on a third party service on their behalf without ever touching their password. At that point, yes, deal with the token juggling, because the alternative, a world where “give the app your password” is normal, is worse in ways that only show up after something’s already gone wrong.&lt;/p&gt;
&lt;p&gt;That’s the real shape of it. Not a login button. A permission system, with real trade-offs, that will absolutely find the one gap in your assumptions if you let two different tokens share a cookie key and call it a day.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/31422/oauth-is-not-login-i-found-that-out-the-hard-way-in-production</guid>
<pubDate>Fri, 16 Oct 2026 17:09:26 +0000</pubDate>
</item>
<item>
<title>Your NextAuth callbackUrl Might Be an Open Redirect Waiting to Happen</title>
<link>https://coderlegion.com/30794/your-nextauth-callbackurl-might-be-an-open-redirect-waiting-to-happen</link>
<description>&lt;p&gt;Redirecting a user back to the page they were trying to reach after they log in is good, expected UX. Nobody wants to log in and land back on the homepage when they were actually trying to read a specific article or check out a specific product. The common way this gets implemented is also a common way to accidentally build an open redirect vulnerability directly into your auth flow.&lt;/p&gt;
&lt;h3 id=&quot;the-pattern-that-looks-completely-reasonable&quot;&gt;The Pattern That Looks Completely Reasonable&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;// app/login/page.tsx
export default function LoginPage({ searchParams }: { searchParams: { callbackUrl?: string } }) {
  return (
    &amp;lt;form action={async (formData: FormData) =&amp;gt; {
      'use server';
      const result = await signIn('credentials', {
        email: formData.get('email'),
        password: formData.get('password'),
        redirect: false,
      });

      if (result?.ok) {
        redirect(searchParams.callbackUrl || '/dashboard');
      }
    }}&amp;gt;
      {/* form fields */}
    &amp;lt;/form&amp;gt;
  );
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This is a genuinely common pattern, a protected page redirects an unauthenticated visitor to &lt;code&gt;/login?callbackUrl=/dashboard/reports&lt;/code&gt;, and after a successful login, the app sends them back to wherever that parameter points. It works correctly, and it feels like a small, harmless convenience feature, right up until someone realizes that &lt;code&gt;callbackUrl&lt;/code&gt; value came straight from a query string the visitor controls entirely.&lt;/p&gt;
&lt;h3 id=&quot;what-an-attacker-actually-does-with-this&quot;&gt;What an Attacker Actually Does With This&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;https://yourapp.com/login?callbackUrl=https://evi1-yourapp-login.com/phish
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If your redirect logic trusts the &lt;code&gt;callbackUrl&lt;/code&gt; value without validating that it actually points somewhere inside your own app, a victim who clicks a link like this, maybe from a phishing email that looks legitimate because the domain in the visible link is genuinely &lt;code&gt;yourapp.com&lt;/code&gt;, logs in successfully on your real site, with their real credentials, and then gets silently redirected to an attacker-controlled page immediately afterward. That page can look like anything, a fake &quot;session expired, please log in again&quot; prompt designed to harvest the same credentials a second time, now with the victim's guard completely down because they just successfully authenticated on the real site seconds earlier.&lt;/p&gt;
&lt;p&gt;This is specifically dangerous because every part of the actual login happened on your real, legitimate domain. Nothing about the login page itself looked suspicious, because it wasn't, the vulnerability is entirely in what happens after a genuine, successful login.&lt;/p&gt;
&lt;h3 id=&quot;why-this-is-easy-to-miss&quot;&gt;Why This Is Easy to Miss&lt;/h3&gt;
&lt;p&gt;Redirect-after-login code gets tested by developers clicking through their own app, which only ever produces &lt;code&gt;callbackUrl&lt;/code&gt; values that point to real internal pages, because that's the only way anyone naturally encounters this flow during development. The idea of someone deliberately crafting a malicious external value for this parameter doesn't come up unless security testing specifically targets it, since from a pure functionality standpoint, the feature works perfectly for every normal case anyone would think to try.&lt;/p&gt;
&lt;h3 id=&quot;the-fix-validate-the-redirect-target-is-actually-internal&quot;&gt;The Fix: Validate the Redirect Target Is Actually Internal&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// lib/safe-redirect.ts
export function getSafeRedirectUrl(callbackUrl: string | undefined, fallback = '/dashboard'): string {
  if (!callbackUrl) return fallback;

  // Reject anything that isn't a relative path starting with exactly one slash.
  // &quot;//evil.com&quot; and &quot;https://evil.com&quot; both get rejected here,
  // protocol-relative URLs are a classic bypass for a naive check.
  if (!callbackUrl.startsWith('/') || callbackUrl.startsWith('//')) {
    return fallback;
  }

  return callbackUrl;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;if (result?.ok) {
  redirect(getSafeRedirectUrl(searchParams.callbackUrl));
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The specific check for a leading &lt;code&gt;//&lt;/code&gt; matters more than it looks like it should. A browser interprets &lt;code&gt;//evil.com&lt;/code&gt; as a protocol-relative URL, meaning it resolves to &lt;code&gt;https://evil.com&lt;/code&gt; using whatever protocol the current page is on, and a naive check that only verifies &quot;does this start with a single slash&quot; can be bypassed entirely by that exact pattern. Rejecting anything that doesn't match a genuine single-slash-prefixed relative path closes this specific gap.&lt;/p&gt;
&lt;p&gt;If you're using NextAuth's built-in redirect callback, the same principle applies there directly, NextAuth exposes a &lt;code&gt;redirect&lt;/code&gt; callback specifically so you can validate target URLs centrally:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// auth.ts
export const authOptions: NextAuthOptions = {
  callbacks: {
    async redirect({ url, baseUrl }) {
      // Only allow redirects to the same origin as the app itself
      if (url.startsWith(baseUrl)) return url;
      if (url.startsWith('/')) return `${baseUrl}${url}`;
      return baseUrl;
    },
  },
};
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;the-rule&quot;&gt;The Rule&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;Any redirect target sourced from user-controllable input, a query parameter, a form field, a referrer header, needs to be validated as pointing somewhere inside your own app before you redirect to it.&lt;/strong&gt; &quot;It's just a convenience redirect&quot; is exactly the framing that makes this vulnerability easy to introduce without noticing, since the feature's happy path looks completely benign and only the deliberately crafted input reveals the gap.&lt;/p&gt;
&lt;p&gt;I validate every redirect target this way in the auth flows across my &lt;a rel=&quot;nofollow&quot; href=&quot;https://neurodash-dashbord.vercel.app/&quot;&gt;Next.js dashboard templates&lt;/a&gt;, since login and post-auth redirects show up in basically every project and this is a genuinely easy detail to skip under normal development pressure. More on the broader auth patterns I build around at &lt;a rel=&quot;nofollow&quot; href=&quot;https://pixelanas.com&quot;&gt;pixelanas.com&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Get the templates:&lt;/strong&gt; &lt;a rel=&quot;nofollow&quot; href=&quot;https://pixelanas.gumroad.com&quot;&gt;https://pixelanas.gumroad.com&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;Go check your own login flow for a &lt;code&gt;callbackUrl&lt;/code&gt;, &lt;code&gt;returnTo&lt;/code&gt;, or &lt;code&gt;next&lt;/code&gt; parameter that gets redirected to without validation. If you find one, try crafting a &lt;code&gt;//evil.com&lt;/code&gt; style value against it. Drop what you find in the comments.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;em&gt;Anas, full-stack Next.js developer building SaaS products and premium templates. X: &lt;a rel=&quot;nofollow&quot; href=&quot;https://x.com/ASheikh69751&quot;&gt;@ASheikh69751&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/30794/your-nextauth-callbackurl-might-be-an-open-redirect-waiting-to-happen</guid>
<pubDate>Mon, 12 Oct 2026 10:39:32 +0000</pubDate>
</item>
<item>
<title>You Changed a User's Role in the Database, But Their JWT Doesn't Know That Yet</title>
<link>https://coderlegion.com/30591/you-changed-a-users-role-in-the-database-but-their-jwt-doesnt-know-that-yet</link>
<description>&lt;p&gt;You find out a user's permissions need to change right now, maybe they're being demoted from admin, maybe their account access is being revoked entirely after an incident. You update the database. The change looks instant in your admin panel. Meanwhile, that user keeps acting with their old permissions for as long as their existing session token stays valid, sometimes for hours, sometimes for days, and nothing about your database update touched that.&lt;/p&gt;
&lt;h3 id=&quot;why-this-happens-jwts-don-t-check-back-in&quot;&gt;Why This Happens: JWTs Don't Check Back In&lt;/h3&gt;
&lt;p&gt;A JWT-based session, the kind most Next.js apps use with something like NextAuth, is a self-contained token. Once it's issued, your server verifies it by checking its signature and expiration, not by looking anything up in your database. That's the entire point of a stateless token, it avoids a database round trip on every single request.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// middleware.ts
export async function middleware(request: NextRequest) {
  const token = await getToken({ req: request });

  // This only checks: is the signature valid, has it expired?
  // It does NOT check: does this role still match what's in the database right now?
  if (token?.role !== 'admin') {
    return NextResponse.redirect(new URL('/unauthorized', request.url));
  }

  return NextResponse.next();
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The role embedded in that token was correct the moment it was issued. It is not re-verified against the database on every request, because doing so would defeat the entire performance benefit of using a stateless token in the first place. If you update the user's role in the database five minutes after their token was issued, the token itself still says the old role, and will keep saying that until it expires and they're forced to log in again.&lt;/p&gt;
&lt;h3 id=&quot;where-this-actually-causes-real-problems&quot;&gt;Where This Actually Causes Real Problems&lt;/h3&gt;
&lt;p&gt;For a routine role change, like a title update that doesn't affect sensitive access, a short delay before it takes effect is a minor inconvenience, at worst, mildly confusing. For a security-sensitive revocation, suspending an account after detecting suspicious activity, revoking admin access after an employee departure, removing a compromised API key's associated session, that same delay is a real exposure window. The access you believed you'd just shut off is still fully functional from the attacker's or departed employee's side, for however long their existing token remains valid.&lt;/p&gt;
&lt;h3 id=&quot;why-this-is-easy-to-miss-building-the-app&quot;&gt;Why This Is Easy to Miss Building the App&lt;/h3&gt;
&lt;p&gt;Session and role logic almost always gets tested by logging in fresh after making a change, which issues a brand new token with the updated role baked in, making the fix look like it worked perfectly. The actual bug only shows up for a user who's already logged in with an existing token at the moment the change happens, a scenario that's genuinely easy to never think to test, since it requires two separate sessions and specific timing to notice.&lt;/p&gt;
&lt;h3 id=&quot;the-fix-force-re-verification-for-anything-sensitive&quot;&gt;The Fix: Force Re-Verification for Anything Sensitive&lt;/h3&gt;
&lt;p&gt;The cleanest general fix is shortening token lifetime for sensitive roles and pairing it with refresh logic that re-checks the database:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// auth.ts (NextAuth config)
export const authOptions: NextAuthOptions = {
  session: {
    strategy: 'jwt',
    maxAge: 15 * 60, // 15 minutes instead of days, forces more frequent re-issuance
  },
  callbacks: {
    async jwt({ token, user, trigger }) {
      if (trigger === 'update' || !token.lastChecked || Date.now() - (token.lastChecked as number) &amp;gt; 60_000) {
        // Re-fetch the current role from the database periodically,
        // not just at initial login
        const dbUser = await User.findById(token.sub).select('role status');
        if (!dbUser || dbUser.status === 'suspended') {
          return { ...token, role: null, revoked: true };
        }
        token.role = dbUser.role;
        token.lastChecked = Date.now();
      }
      return token;
    },
  },
};
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;For genuinely urgent revocations, like an active security incident, token expiration alone isn't fast enough, and this is where some form of server-side revocation check becomes necessary, a denylist of revoked token IDs checked against a fast store like Redis, even if you're otherwise fully stateless everywhere else. It adds back a small lookup cost, but only for the specific case where &quot;wait until the token expires naturally&quot; genuinely isn't an acceptable answer.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// A minimal revocation check added to middleware
const isRevoked = await redis.sismember('revoked_sessions', token.jti);
if (isRevoked) {
  return NextResponse.redirect(new URL('/login', request.url));
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;the-rule-1&quot;&gt;The Rule&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;A JWT's claims are a snapshot from the moment it was issued, not a live view of your database.&lt;/strong&gt; Any role or permission check based purely on token contents can be stale for as long as that token remains valid. For routine data, that staleness is usually an acceptable tradeoff for performance. For anything where immediate revocation actually matters, you need either short-lived tokens with periodic re-verification, or an explicit revocation check, because the token itself has no way of knowing anything changed.&lt;/p&gt;
&lt;p&gt;I treat this distinction explicitly in the auth setup behind my &lt;a rel=&quot;nofollow&quot; href=&quot;/blog/mongodb-mongoose-patterns-2026&quot;&gt;Next.js dashboard templates&lt;/a&gt;, where role-based access control needs to actually mean something the moment it changes, not sometime later.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Get the templates:&lt;/strong&gt; &lt;a rel=&quot;nofollow&quot; href=&quot;https://pixelanas.gumroad.com&quot;&gt;https://pixelanas.gumroad.com&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;Check your own app, specifically how fast a role change or account suspension actually takes effect for a user with an existing session. If the honest answer is &quot;whenever their token expires,&quot; that's worth deciding on purpose rather than by default. Drop what you find in the comments.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;em&gt;Anas, full-stack Next.js developer building SaaS products and premium templates. X: &lt;a rel=&quot;nofollow&quot; href=&quot;https://x.com/ASheikh69751&quot;&gt;@ASheikh69751&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/30591/you-changed-a-users-role-in-the-database-but-their-jwt-doesnt-know-that-yet</guid>
<pubDate>Sat, 10 Oct 2026 13:33:53 +0000</pubDate>
</item>
<item>
<title>Fixing Threads API Authorization Code Failures</title>
<link>https://coderlegion.com/29709/fixing-threads-api-authorization-code-failures</link>
<description>&lt;p&gt;Integrating the Threads API requires navigating a three-legged OAuth process that can stall at several distinct points. When an application fails to exchange an authorization code for a valid access token, debugging often becomes a frustrating exercise in trial and error. The API returns cryptic numeric codes and generic error messages that rarely point to the root cause of the failure.&lt;/p&gt;
&lt;p&gt;The core problem stems from strict validation rules across the authorization code, the application credentials, the redirect URIs, and token families. Understanding why these failures occur helps engineering teams establish a reliable diagnostic process and complete successful token exchanges without burning single-use codes.&lt;/p&gt;
&lt;h3 id=&quot;the-three-legged-token-flow&quot;&gt;The Three-Legged Token Flow&lt;/h3&gt;
&lt;p&gt;The Threads API uses a standard OAuth three-legged flow to establish user-level access. First, the user visits an authorization URL hosted by the platform, which redirects back to the application with a short-lived authorization code. Second, the backend exchanges this authorization code for a short-lived user access token via a POST request. Third, this short-lived token is exchanged for a sixty-day long-lived token.&lt;/p&gt;
&lt;p&gt;Each leg of this journey relies on precise inputs. The authorization code expires within minutes and can only be used once. If any parameter in the subsequent requests deviates from the expected format, the platform rejects the exchange.&lt;/p&gt;
&lt;h3 id=&quot;the-four-failure-modes&quot;&gt;The Four Failure Modes&lt;/h3&gt;
&lt;p&gt;When token exchanges fail, the error typically maps to one of four specific conditions:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Wrong token family (452 Session key invalid)&lt;/strong&gt;: Tokens minted in generic testing surfaces or alternative Meta dashboards belong to a different token family. The Threads endpoint rejects these tokens outright.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Wrong secret of two&lt;/strong&gt;: Meta frequently issues multiple app ID and secret pairs for a single project. The Threads API requires the specific Threads credential pair, and because both secrets share the same length, visual inspection is unreliable.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Non-identical redirect URI (36008)&lt;/strong&gt;: The redirect URI supplied during the code exchange must match the initial authorization request byte-for-byte, including trailing slashes and parameter encodings.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Stale or consumed codes (36006)&lt;/strong&gt;: Authorization codes are strictly single-use. Retrying a failed request with an old code or keeping multiple browser tabs open will trigger this failure.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;the-diagnostic-ladder&quot;&gt;The Diagnostic Ladder&lt;/h3&gt;
&lt;p&gt;To isolate these issues without wasting tokens, follow a strict diagnostic order. Testing items sequentially prevents compounding errors.&lt;/p&gt;
&lt;p&gt;First, verify the token family. If a 452 error occurs, check whether the credentials originated from the correct Meta app dashboard configured specifically for Threads rather than general Facebook Login products.&lt;/p&gt;
&lt;p&gt;Second, test the secret pairing programmatically. Because visual checks fail on same-length secrets, send a lightweight client credentials grant or inspection call using the suspected secret to confirm authentication success before attempting the user code exchange.&lt;/p&gt;
&lt;p&gt;Third, compare redirect URIs at the byte level. Print out the exact string sent in both the authorization redirect and the token exchange POST body. A missing trailing slash or an unencoded character will immediately cause a 36008 error.&lt;/p&gt;
&lt;p&gt;Finally, treat authorization codes as perishable singletons. Open a fresh authorization URL, capture the code immediately, and execute the exchange request within seconds.&lt;/p&gt;
&lt;h3 id=&quot;code-example-for-token-exchange&quot;&gt;Code Example for Token Exchange&lt;/h3&gt;
&lt;p&gt;Executing the token exchange correctly requires sending the proper POST parameters with the right application credentials. Here is an example request using curl:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;curl -X POST &quot;https://graph.threads.net/oauth/access_token&quot; \
  -F &quot;client_id=YOUR_THREADS_CLIENT_ID&quot; \
  -F &quot;client_secret=YOUR_THREADS_CLIENT_SECRET&quot; \
  -F &quot;grant_type=authorization_code&quot; \
  -F &quot;redirect_uri=https://example.com/callback&quot; \
  -F &quot;code=AQD_SAMPLE_AUTHORIZATION_CODE&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If successful, the endpoint returns a JSON payload containing the short-lived access token and the user ID. Immediately pass this token to the long-lived token endpoint to secure a sixty-day session.&lt;/p&gt;
&lt;h3 id=&quot;after-the-token-lands&quot;&gt;After the Token Lands&lt;/h3&gt;
&lt;p&gt;Once the sixty-day token is successfully retrieved, verify its permissions by calling a read-scoped endpoint before storing it. Store the final token securely in platform secret storage or a key-value store with strict rotation policies. Never commit secrets to version control or leave them exposed in local configuration files. For a related implementation, see &lt;a rel=&quot;nofollow&quot; href=&quot;https://raylabs.app/articles/android-secrets-local-config-ci/&quot;&gt;Secrets Local Config Ci&lt;/a&gt;.&lt;/p&gt;
&lt;h3 id=&quot;conclusion&quot;&gt;Conclusion&lt;/h3&gt;
&lt;p&gt;Navigating the Threads API authentication flow depends on precise adherence to credential pairings, token families, and byte-level URI matching. By following a structured diagnostic approach and treating authorization codes as perishable singletons, developers can eliminate invalid-code failures and build resilient social integrations. For a related implementation, see &lt;a rel=&quot;nofollow&quot; href=&quot;https://raylabs.app/articles/site-wide-structured-data-and-social-graph-integration/&quot;&gt;Site Wide Structured Data Social Graph&lt;/a&gt;.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/29709/fixing-threads-api-authorization-code-failures</guid>
<pubDate>Sun, 04 Oct 2026 09:03:46 +0000</pubDate>
</item>
<item>
<title>From Broken Google OAuth to a Stable Sepolia E2E Environment: Engineering MyZubster on a VPS</title>
<link>https://coderlegion.com/28744/from-broken-google-oauth-to-stable-sepolia-e2e-environment-engineering-myzubster-on-vps</link>
<description>&lt;h2 id=&quot;from-broken-google-oauth-to-a-stable-sepolia-e2e-environment-engineering-myzubster-on-a-vps&quot;&gt;From Broken Google OAuth to a Stable Sepolia E2E Environment: Engineering MyZubster on a VPS&lt;/h2&gt;
&lt;p&gt;Over the last development cycle, we worked on stabilizing the MyZubster Sepolia environment and turning what was initially a collection of partially working services into a much more predictable end-to-end infrastructure.&lt;/p&gt;
&lt;p&gt;The work involved several layers of the stack:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Node.js / Express backend&lt;/li&gt;
&lt;li&gt;Google OAuth 2.0&lt;/li&gt;
&lt;li&gt;React frontend&lt;/li&gt;
&lt;li&gt;Ethereum Sepolia testing&lt;/li&gt;
&lt;li&gt;MongoDB&lt;/li&gt;
&lt;li&gt;PM2 process management&lt;/li&gt;
&lt;li&gt;Cloudflare tunneling&lt;/li&gt;
&lt;li&gt;VPS deployment&lt;/li&gt;
&lt;li&gt;Environment configuration&lt;/li&gt;
&lt;li&gt;Urban Lab / I-ECO-01 services&lt;/li&gt;
&lt;li&gt;Git branch and deployment management&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;This article documents what we fixed, how we diagnosed the problems, what is working now, and what still needs to be completed.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;1-the-starting-point&quot;&gt;1. The Starting Point&lt;/h3&gt;
&lt;p&gt;The Sepolia environment was running on a VPS with several services managed by PM2.&lt;/p&gt;
&lt;p&gt;The main components included:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;MyZubster Sepolia backend&lt;/li&gt;
&lt;li&gt;MyZubster Sepolia frontend&lt;/li&gt;
&lt;li&gt;MyZubster Social&lt;/li&gt;
&lt;li&gt;MyZubster Web&lt;/li&gt;
&lt;li&gt;Urban Lab / I-ECO-01&lt;/li&gt;
&lt;li&gt;Cloudflared&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The environment was operational, but two important areas were unstable.&lt;/p&gt;
&lt;p&gt;The first was Google authentication.&lt;/p&gt;
&lt;p&gt;Users could start the OAuth flow and Google returned the authorization callback, but the backend failed during the authorization-code exchange.&lt;/p&gt;
&lt;p&gt;The second problem was the Urban Lab service.&lt;/p&gt;
&lt;p&gt;It was trapped in a PM2 restart loop, generating millions of restarts because runtime dependencies required by &lt;code&gt;server.js&lt;/code&gt; were missing.&lt;/p&gt;
&lt;p&gt;We debugged these independently instead of treating the VPS as a single black box.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;2-debugging-google-oauth&quot;&gt;2. Debugging Google OAuth&lt;/h2&gt;
&lt;p&gt;The original application error was generic:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Login Google non riuscito
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That message was not enough to identify whether the problem was caused by:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;the authorization code,&lt;/li&gt;
&lt;li&gt;OAuth state,&lt;/li&gt;
&lt;li&gt;callback URI,&lt;/li&gt;
&lt;li&gt;client ID,&lt;/li&gt;
&lt;li&gt;client secret,&lt;/li&gt;
&lt;li&gt;Google configuration,&lt;/li&gt;
&lt;li&gt;proxy forwarding,&lt;/li&gt;
&lt;li&gt;or application logic.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;We therefore instrumented the token exchange temporarily.&lt;/p&gt;
&lt;p&gt;The relevant backend flow exchanges the authorization code against Google's token endpoint and then retrieves the verified Google user profile.&lt;/p&gt;
&lt;p&gt;The diagnostic logging revealed the actual failure:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;status: 401
error: invalid_client
description: The provided client secret is invalid.
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This immediately changed the investigation.&lt;/p&gt;
&lt;p&gt;The callback itself was reaching the backend correctly.&lt;/p&gt;
&lt;p&gt;The OAuth state existed.&lt;/p&gt;
&lt;p&gt;The authorization code existed.&lt;/p&gt;
&lt;p&gt;The problem was the credentials used during the token exchange.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;3-validating-the-runtime-environment&quot;&gt;3. Validating the Runtime Environment&lt;/h2&gt;
&lt;p&gt;One of the important lessons from this debugging session was that validating &lt;code&gt;.env&lt;/code&gt; alone is not sufficient.&lt;/p&gt;
&lt;p&gt;We checked both the configuration file and the running PM2 process.&lt;/p&gt;
&lt;p&gt;The Google client configuration was inspected without printing the complete secret.&lt;/p&gt;
&lt;p&gt;We verified properties such as:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;CLIENT ID
SECRET PRESENT
SECRET LENGTH
SECRET FORMAT
CALLBACK URL
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The configured callback was:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;https://sepolia.myzubster.com/api/auth/social/google/callback
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;We then discovered that the previously configured secret was not a valid secret for the OAuth client being used.&lt;/p&gt;
&lt;p&gt;After replacing it with the correct Google OAuth client secret, the token exchange started succeeding.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;4-google-oauth-successfully-completed&quot;&gt;4. Google OAuth Successfully Completed&lt;/h2&gt;
&lt;p&gt;After correcting the OAuth credentials, the backend began reporting successful callbacks.&lt;/p&gt;
&lt;p&gt;The flow became:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Browser
   |
   v
Google Authorization
   |
   v
/api/auth/social/google/callback
   |
   v
Authorization Code Exchange
   |
   v
Google Access Token
   |
   v
Google UserInfo
   |
   v
MyZubster account verification
   |
   v
Social login ticket
   |
   v
Frontend
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The backend successfully received callbacks containing both the authorization code and OAuth state.&lt;/p&gt;
&lt;p&gt;Most importantly, the previous &lt;code&gt;invalid_client&lt;/code&gt; token error disappeared.&lt;/p&gt;
&lt;p&gt;The public provider endpoint also confirmed:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;{
  &quot;success&quot;: true,
  &quot;data&quot;: {
    &quot;providers&quot;: {
      &quot;google&quot;: true,
      &quot;github&quot;: false,
      &quot;facebook&quot;: false
    }
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Google authentication is therefore enabled in the Sepolia environment.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;5-fixing-the-oauth-redirect&quot;&gt;5. Fixing the OAuth Redirect&lt;/h2&gt;
&lt;p&gt;Authentication succeeding at Google was only part of the problem.&lt;/p&gt;
&lt;p&gt;The backend was still redirecting successful logins to an old Vercel deployment.&lt;/p&gt;
&lt;p&gt;The runtime destination was effectively:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;old Vercel deployment
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;instead of:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;https://sepolia.myzubster.com
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;We corrected the runtime environment so the relevant public URLs resolve to the Sepolia deployment.&lt;/p&gt;
&lt;p&gt;The running PM2 process was verified with:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;FRONTEND_URL=https://sepolia.myzubster.com
GATEWAY_PUBLIC_URL=https://sepolia.myzubster.com
PUBLIC_APP_URL=https://sepolia.myzubster.com
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This was an important distinction.&lt;/p&gt;
&lt;p&gt;Updating &lt;code&gt;.env&lt;/code&gt; is not necessarily equivalent to updating the environment of an already running process.&lt;/p&gt;
&lt;p&gt;For production-style deployments, the effective runtime configuration must always be verified.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;6-testing-the-complete-oauth-path&quot;&gt;6. Testing the Complete OAuth Path&lt;/h2&gt;
&lt;p&gt;We then tested the complete flow again.&lt;/p&gt;
&lt;p&gt;The callback reached the backend with:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;hasCode: true
hasState: true
hasError: false
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The Google token exchange completed successfully.&lt;/p&gt;
&lt;p&gt;No new Google token error appeared.&lt;/p&gt;
&lt;p&gt;The backend remained online.&lt;/p&gt;
&lt;p&gt;The provider endpoint remained available.&lt;/p&gt;
&lt;p&gt;The PM2 process PID remained stable.&lt;/p&gt;
&lt;p&gt;This gave us evidence that the fix was not simply masking an application crash.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;7-removing-temporary-debug-instrumentation&quot;&gt;7. Removing Temporary Debug Instrumentation&lt;/h2&gt;
&lt;p&gt;Temporary instrumentation is useful during incident analysis, but it should not become permanent application noise.&lt;/p&gt;
&lt;p&gt;After confirming the OAuth path, we removed the temporary HTTP callback logger.&lt;/p&gt;
&lt;p&gt;We retained useful error-level logging around OAuth failures:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-javascript&quot;&gt;console.error('[OAUTH-CALLBACK-ERROR]', {
  provider,
  message: String(error?.message || error)
});
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This provides enough information for future troubleshooting without logging authorization codes, OAuth state values, access tokens, or secrets.&lt;/p&gt;
&lt;p&gt;That distinction matters.&lt;/p&gt;
&lt;p&gt;Authentication debugging must provide observability without accidentally creating a credential-leak mechanism.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;8-stabilizing-urban-lab-i-eco-01&quot;&gt;8. Stabilizing Urban Lab / I-ECO-01&lt;/h2&gt;
&lt;p&gt;At the same time, another service on the VPS had a completely different problem.&lt;/p&gt;
&lt;p&gt;The &lt;code&gt;urbanlab&lt;/code&gt; PM2 process had accumulated an extremely large restart count.&lt;/p&gt;
&lt;p&gt;Inspection of the error log showed:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Error: Cannot find module 'helmet'
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The server required:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-javascript&quot;&gt;require('dotenv').config();
const express = require('express');
const cors = require('cors');
const helmet = require('helmet');
const mongoose = require('mongoose');
const { createServer } = require('http');
const { Server } = require('socket.io');
const winston = require('winston');
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;But &lt;code&gt;package.json&lt;/code&gt; originally contained only:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;cors
dotenv
express
mongoose
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The runtime therefore expected modules that the project did not declare/install.&lt;/p&gt;
&lt;p&gt;We added the missing dependencies:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;helmet
socket.io
winston
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;After installation, each required module could be resolved successfully.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;9-stopping-a-multi-million-restart-pm2-loop&quot;&gt;9. Stopping a Multi-Million-Restart PM2 Loop&lt;/h2&gt;
&lt;p&gt;Once the missing dependencies were installed, Urban Lab started normally.&lt;/p&gt;
&lt;p&gt;The server reported:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Server started on port 5002
MongoDB connected
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Its health endpoint returned:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;{
  &quot;status&quot;: &quot;ok&quot;,
  &quot;service&quot;: &quot;I-ECO-01&quot;,
  &quot;mongodb&quot;: &quot;connected&quot;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;We then compared the PM2 process before and after a waiting period.&lt;/p&gt;
&lt;p&gt;The PID remained unchanged.&lt;/p&gt;
&lt;p&gt;The restart counter remained unchanged.&lt;/p&gt;
&lt;p&gt;That confirmed that the restart loop had actually stopped.&lt;/p&gt;
&lt;p&gt;This is much more meaningful than simply seeing:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;status: online
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;for a few seconds.&lt;/p&gt;
&lt;p&gt;A process can repeatedly appear online while constantly crashing and restarting.&lt;/p&gt;
&lt;p&gt;Process stability needs to be measured over time.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;10-current-vps-architecture&quot;&gt;10. Current VPS Architecture&lt;/h2&gt;
&lt;p&gt;The resulting environment now looks approximately like this:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;                    Internet
                       |
                       v
                Cloudflare Tunnel
                       |
                       v
            sepolia.myzubster.com
                       |
          +------------+------------+
          |                         |
          v                         v
     React Frontend            Express Backend
                                   |
                    +--------------+--------------+
                    |              |              |
                    v              v              v
                 MongoDB       Google OAuth   Sepolia EVM
                    |
                    v
              Application Data

Additional VPS services:

PM2
 |
 +-- MyZubster Sepolia Backend
 +-- MyZubster Sepolia Frontend
 +-- MyZubster Social
 +-- MyZubster Web
 +-- Urban Lab / I-ECO-01
 +-- Cloudflared
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;PM2 is configured under systemd, and the active process list has been saved using:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;pm2 save
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This means the current process configuration can be restored after a reboot through the existing PM2 startup integration.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;11-git-hygiene&quot;&gt;11. Git Hygiene&lt;/h2&gt;
&lt;p&gt;Another important part of the work was separating debugging artifacts from actual source changes.&lt;/p&gt;
&lt;p&gt;During debugging we created several temporary backups.&lt;/p&gt;
&lt;p&gt;Before committing, those files were removed from the working tree.&lt;/p&gt;
&lt;p&gt;We also explicitly verified that &lt;code&gt;.env&lt;/code&gt; remained ignored.&lt;/p&gt;
&lt;p&gt;The final source change was isolated and committed as:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;fix(auth): improve OAuth callback error logging
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The resulting commit was:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;02f06f22
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The working tree was clean after the commit.&lt;/p&gt;
&lt;p&gt;The branch:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;vps/sepolia-e2e-test
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;was then pushed to the remote repository.&lt;/p&gt;
&lt;p&gt;This gives us a reproducible Git checkpoint corresponding to the working VPS state without committing the environment secrets.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;12-security-considerations&quot;&gt;12. Security Considerations&lt;/h2&gt;
&lt;p&gt;One of the most important lessons from this work concerns secrets.&lt;/p&gt;
&lt;p&gt;OAuth client secrets, API keys, database credentials, private keys, GitHub tokens, JWT signing secrets, and similar credentials should never be committed to Git.&lt;/p&gt;
&lt;p&gt;The repository correctly ignores &lt;code&gt;.env&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;But &lt;code&gt;.gitignore&lt;/code&gt; is only one layer of protection.&lt;/p&gt;
&lt;p&gt;The longer-term architecture should move production credentials toward dedicated secret management and controlled deployment-time injection.&lt;/p&gt;
&lt;p&gt;We also deliberately avoided logging:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Google client secret
OAuth authorization code
OAuth state
access tokens
JWTs
database passwords
private keys
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Observability and secret exposure must remain separate concerns.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;13-what-is-working-now&quot;&gt;13. What Is Working Now&lt;/h2&gt;
&lt;p&gt;At this stage we have achieved several concrete milestones.&lt;/p&gt;
&lt;p&gt;Google OAuth can complete the server-side authorization-code flow.&lt;/p&gt;
&lt;p&gt;The Sepolia backend exposes Google as an enabled authentication provider.&lt;/p&gt;
&lt;p&gt;The callback is routed through the public Sepolia infrastructure.&lt;/p&gt;
&lt;p&gt;The application runtime is using the intended Sepolia frontend/public URLs.&lt;/p&gt;
&lt;p&gt;PM2 is running the MyZubster services.&lt;/p&gt;
&lt;p&gt;PM2 configuration has been persisted.&lt;/p&gt;
&lt;p&gt;Urban Lab / I-ECO-01 is no longer trapped in its missing-module restart loop.&lt;/p&gt;
&lt;p&gt;Its MongoDB connection is operational.&lt;/p&gt;
&lt;p&gt;The I-ECO-01 health endpoint responds successfully.&lt;/p&gt;
&lt;p&gt;The OAuth debugging changes have been cleaned up.&lt;/p&gt;
&lt;p&gt;The relevant source change has been committed and pushed to a dedicated branch.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;14-what-still-needs-to-be-done&quot;&gt;14. What Still Needs to Be Done&lt;/h2&gt;
&lt;p&gt;A working OAuth callback does not mean the entire platform is production-ready.&lt;/p&gt;
&lt;p&gt;Several areas still need engineering work.&lt;/p&gt;
&lt;h3 id=&quot;authentication-hardening&quot;&gt;Authentication hardening&lt;/h3&gt;
&lt;p&gt;The OAuth implementation should receive additional automated tests covering:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;invalid state
expired state
missing code
provider errors
invalid client configuration
token endpoint failures
userinfo failures
unverified email
expired social-login tickets
replay attempts
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;GitHub and Facebook providers are currently disabled according to the provider endpoint and can be implemented or enabled separately when their configuration is ready.&lt;/p&gt;
&lt;h3 id=&quot;deployment-reproducibility&quot;&gt;Deployment reproducibility&lt;/h3&gt;
&lt;p&gt;The VPS currently represents an important working environment.&lt;/p&gt;
&lt;p&gt;The next goal should be making that environment reproducible from source and configuration rather than depending on manual server history.&lt;/p&gt;
&lt;p&gt;That means documenting and eventually automating:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;dependency installation
environment injection
PM2 configuration
Cloudflare configuration
frontend startup
backend startup
health checks
rollback
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;monitoring&quot;&gt;Monitoring&lt;/h3&gt;
&lt;p&gt;A process being online is not sufficient operational monitoring.&lt;/p&gt;
&lt;p&gt;We want visibility into:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;restart rate
HTTP error rate
OAuth failure rate
MongoDB availability
response latency
memory consumption
CPU consumption
Sepolia RPC failures
WebSocket availability
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;secret-management&quot;&gt;Secret management&lt;/h3&gt;
&lt;p&gt;Production credentials should eventually be rotated and managed through a dedicated secret-management workflow.&lt;/p&gt;
&lt;h3 id=&quot;ci-cd&quot;&gt;CI/CD&lt;/h3&gt;
&lt;p&gt;The current branch and commit structure gives us a good foundation for moving toward automated deployment.&lt;/p&gt;
&lt;p&gt;The desired flow is:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Developer
   |
   v
Git branch
   |
   v
Pull Request
   |
   v
Automated Tests
   |
   v
Security Checks
   |
   v
Merge
   |
   v
Sepolia Deployment
   |
   v
Health Checks
   |
   v
Production Promotion
&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h2 id=&quot;15-where-we-want-to-go&quot;&gt;15. Where We Want to Go&lt;/h2&gt;
&lt;p&gt;The objective is larger than simply making Google login work.&lt;/p&gt;
&lt;p&gt;The Sepolia environment is becoming the integration layer where we can validate the interaction between identity, application state, Web3 payments, AI services, Urban Lab infrastructure, and MyZubster's broader ecosystem.&lt;/p&gt;
&lt;p&gt;The architecture we are moving toward is approximately:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;                         MyZubster
                             |
        +--------------------+--------------------+
        |                    |                    |
        v                    v                    v
     Identity             Web3 Layer          AI Layer
        |                    |                    |
   Google OAuth          Ethereum           AI Services
   GitHub OAuth          Sepolia            Zorgax / Pytho
   Social Identity       Wallets            Automation
        |                    |                    |
        +--------------------+--------------------+
                             |
                             v
                        Core Backend
                             |
             +---------------+---------------+
             |                               |
             v                               v
          MongoDB                       Urban Lab
                                            |
                                  +---------+---------+
                                  |                   |
                                  v                   v
                              IoT / GPS        Environmental Data
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The Sepolia environment gives us a place to integrate these components without treating production as the testing environment.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;16-engineering-lessons&quot;&gt;16. Engineering Lessons&lt;/h2&gt;
&lt;p&gt;Several lessons from this debugging cycle are worth keeping.&lt;/p&gt;
&lt;h4 id=&quot;inspect-the-real-provider-response&quot;&gt;Inspect the real provider response&lt;/h4&gt;
&lt;p&gt;&lt;code&gt;Login failed&lt;/code&gt; tells you almost nothing.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;401 invalid_client
The provided client secret is invalid
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;tells you exactly where to investigate.&lt;/p&gt;
&lt;h4 id=&quot;-env-is-not-necessarily-the-runtime-environment&quot;&gt;&lt;code&gt;.env&lt;/code&gt; is not necessarily the runtime environment&lt;/h4&gt;
&lt;p&gt;Always inspect what the actual process sees.&lt;/p&gt;
&lt;p&gt;This becomes especially important with PM2, containers, systemd, CI/CD, and cloud deployment systems.&lt;/p&gt;
&lt;h4 id=&quot;online-does-not-necessarily-mean-healthy&quot;&gt;&lt;code&gt;online&lt;/code&gt; does not necessarily mean healthy&lt;/h4&gt;
&lt;p&gt;Urban Lab demonstrated this clearly.&lt;/p&gt;
&lt;p&gt;A service may be repeatedly restarted while PM2 continues reporting it as online between crashes.&lt;/p&gt;
&lt;p&gt;PID stability, restart counters, health checks, and logs provide a much better picture.&lt;/p&gt;
&lt;h4 id=&quot;debug-instrumentation-should-be-temporary&quot;&gt;Debug instrumentation should be temporary&lt;/h4&gt;
&lt;p&gt;Add enough instrumentation to understand the failure.&lt;/p&gt;
&lt;p&gt;Verify the fix.&lt;/p&gt;
&lt;p&gt;Then remove unnecessary diagnostic logging while retaining safe operational error reporting.&lt;/p&gt;
&lt;h4 id=&quot;infrastructure-fixes-should-end-in-git&quot;&gt;Infrastructure fixes should end in Git&lt;/h4&gt;
&lt;p&gt;A VPS fix that exists only in shell history is technical debt.&lt;/p&gt;
&lt;p&gt;Once the behavior is verified, the corresponding source changes should be cleaned, reviewed, committed, and pushed.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;conclusion-1&quot;&gt;Conclusion&lt;/h2&gt;
&lt;p&gt;What began as a Google login failure exposed several deeper infrastructure issues.&lt;/p&gt;
&lt;p&gt;We traced the OAuth flow to the actual Google token exchange, identified an invalid client secret, corrected the runtime configuration, fixed the frontend redirect destination, validated the public callback path, cleaned the debugging instrumentation, stabilized a separate Urban Lab service that had entered a massive PM2 restart loop, verified MongoDB connectivity, persisted the PM2 process state, cleaned the Git working tree, committed the relevant OAuth changes, and pushed the working branch.&lt;/p&gt;
&lt;p&gt;The important outcome is not simply:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;“Google login works.”&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;The more important outcome is that the Sepolia environment is becoming observable, reproducible, testable, and suitable for real end-to-end integration work.&lt;/p&gt;
&lt;p&gt;The next phase is to convert these manually validated infrastructure improvements into automated tests, deployment pipelines, monitoring, stronger secret management, and repeatable environment provisioning.&lt;/p&gt;
&lt;p&gt;That is the path from a working development VPS to a maintainable engineering platform.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/28744/from-broken-google-oauth-to-stable-sepolia-e2e-environment-engineering-myzubster-on-vps</guid>
<pubDate>Sun, 27 Sep 2026 00:54:50 +0000</pubDate>
</item>
<item>
<title>🔐 Understanding SAML, OAuth 2.0 and OpenID Connect</title>
<link>https://coderlegion.com/28633/understanding-saml-oauth-2-0-and-openid-connect</link>
<description>&lt;h3 id=&quot;-what-are-these-protocols-&quot;&gt;&lt;strong&gt;🔎 What Are These Protocols?&lt;/strong&gt;&lt;/h3&gt;
&lt;h4 id=&quot;saml-2-0-security-assertion-markup-language-&quot;&gt;&lt;strong&gt;SAML 2.0 (Security Assertion Markup Language)&lt;/strong&gt;&lt;/h4&gt;
&lt;p&gt;SAML is an &lt;strong&gt;authentication and authorization protocol&lt;/strong&gt; primarily used for &lt;strong&gt;Single Sign-On (SSO) in enterprises&lt;/strong&gt;. It enables users to log in once and access multiple applications without entering credentials again.  &lt;/p&gt;
&lt;p&gt;✔ &lt;strong&gt;Used For:&lt;/strong&gt; Enterprise authentication (e.g., logging into Google Workspace, Salesforce, etc.)&lt;br&gt;
✔ &lt;strong&gt;How It Works:&lt;/strong&gt; Uses &lt;strong&gt;XML-based security assertions&lt;/strong&gt; to exchange authentication and authorization data.&lt;br&gt;
✔ &lt;strong&gt;Downside:&lt;/strong&gt; Verbose XML format, and &lt;strong&gt;not actively developed anymore&lt;/strong&gt;.  &lt;/p&gt;
&lt;h4 id=&quot;oauth-2-0-open-authorization-&quot;&gt;&lt;strong&gt;OAuth 2.0 (Open Authorization)&lt;/strong&gt;&lt;/h4&gt;
&lt;p&gt;OAuth 2.0 is an &lt;strong&gt;authorization protocol&lt;/strong&gt; designed to grant limited access to resources &lt;strong&gt;without exposing user credentials&lt;/strong&gt;. It’s commonly used for &lt;strong&gt;API access and delegated permissions&lt;/strong&gt;.  &lt;/p&gt;
&lt;p&gt;✔ &lt;strong&gt;Used For:&lt;/strong&gt; Third-party API authorization (e.g., &quot;Allow this app to access your Google Drive&quot;)&lt;br&gt;
✔ &lt;strong&gt;How It Works:&lt;/strong&gt; Uses &lt;strong&gt;access tokens&lt;/strong&gt; to grant specific permissions.&lt;br&gt;
✔ &lt;strong&gt;Downside:&lt;/strong&gt; It doesn’t authenticate users—it just authorizes actions.  &lt;/p&gt;
&lt;h4 id=&quot;openid-connect-oidc-&quot;&gt;&lt;strong&gt;OpenID Connect (OIDC)&lt;/strong&gt;&lt;/h4&gt;
&lt;p&gt;OIDC is an &lt;strong&gt;authentication protocol built on top of OAuth 2.0&lt;/strong&gt;. It adds &lt;strong&gt;ID Tokens&lt;/strong&gt; to OAuth, allowing applications to verify a user's identity in a secure, standardized way.  &lt;/p&gt;
&lt;p&gt;✔ &lt;strong&gt;Used For:&lt;/strong&gt; Modern authentication (e.g., &quot;Sign in with Google&quot;)&lt;br&gt;
✔ &lt;strong&gt;How It Works:&lt;/strong&gt; Extends OAuth 2.0 by providing &lt;strong&gt;identity tokens (JWTs)&lt;/strong&gt; to verify users.&lt;br&gt;
✔ &lt;strong&gt;Why It’s Popular:&lt;/strong&gt; &lt;strong&gt;Lightweight, JSON-based&lt;/strong&gt;, and designed for &lt;strong&gt;modern web and mobile apps&lt;/strong&gt;.  &lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;At this point, you might be thinking:  &lt;/p&gt;
&lt;p&gt;&lt;em&gt;&quot;Okay, I just read these definitions, but things still seem a bit confusing.&quot;&lt;/em&gt;  &lt;/p&gt;
&lt;p&gt;&lt;em&gt;&quot;Don’t worry—I felt the same when I first tried to understand them! So, let me break it down in the simplest way possible.&quot;&lt;/em&gt;  &lt;/p&gt;
&lt;p&gt;Before we jump into the story-based explanation, let’s first understand two key terms that will make everything clearer:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;✔ Identity Provider (IdP):&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;The system that authenticates users and confirms their identity.&lt;/p&gt;
&lt;p&gt;Example: Google, Okta, Microsoft Entra ID (Azure AD).&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;✔ Service Provider (SP):&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;The application or website that users want to access after authentication.&lt;/p&gt;
&lt;p&gt;Example: Gmail, Slack, Salesforce.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;💡 Think of it this way:&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;The IdP is like a security guard checking your ID before letting you in.&lt;/p&gt;
&lt;p&gt;The SP is the building or service you’re trying to enter.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;-let-s-understand-this-with-a-story&quot;&gt;&lt;strong&gt;🔍 Let’s Understand This with a Story&lt;/strong&gt;&lt;/h3&gt;
&lt;p&gt;Imagine a big amusement park 🎡 where visitors need special passes to access different rides.&lt;/p&gt;
&lt;p&gt;In this park:&lt;br&gt;
🎟 A Ticket (Token) = Proof of Access&lt;br&gt;
🏛 The Ticket Counter (Identity Provider) = Authenticates visitors&lt;br&gt;
🎢 The Ride (Service Provider) = Grants access based on the ticket&lt;/p&gt;
&lt;p&gt;This analogy will help you understand how authentication and authorization work in each protocol.&lt;/p&gt;
&lt;h4 id=&quot;1-saml-2-0-the-vip-pass-for-employees&quot;&gt;1️⃣ SAML 2.0 – The VIP Pass for Employees&lt;/h4&gt;
&lt;p&gt;📖 Story: The Corporate Park &amp;amp; Employee Pass&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://dev-to-uploads.s3.amazonaws.com/uploads/articles/058hs59gbrj4fp0znofk.jpg&quot; alt=&quot;SAML image with the amusement park analogy, where a visitor uses a single ticket to access multiple rides after verification.&quot;&gt;&lt;/p&gt;
&lt;p&gt;Imagine one company that owns a huge amusement park. Employees don’t buy separate tickets for every ride—they get a VIP Employee Badge 🎫 from the HR office.&lt;/p&gt;
&lt;h5 id=&quot;how-it-works-saml-flow-&quot;&gt;How It Works (SAML Flow):&lt;/h5&gt;
&lt;p&gt;&lt;strong&gt;1️⃣ 🏛 HR Office = Identity Provider (IdP)&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Employees check in at the HR office (login page).&lt;/p&gt;
&lt;p&gt;The HR office verifies who they are and issues a VIP badge (SAML token).&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;2️⃣ 🎢 Amusement Park Rides = Service Providers (SP)&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;The employee shows the badge at any ride’s entrance.&lt;/p&gt;
&lt;p&gt;The ride operator trusts the badge (without asking for a password).&lt;/p&gt;
&lt;p&gt;The employee gets access to the ride (authorization).&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Key Takeaways:&lt;/strong&gt;&lt;br&gt;
✅ SSO (Single Sign-On): Employees log in once and access multiple rides.&lt;br&gt;
✅ Authentication + Authorization: The badge confirms who you are and grants access to specific rides.&lt;br&gt;
✅ Enterprise Use: Best for corporate authentication, but not ideal for modern mobile/web apps.&lt;br&gt;
✅ Legacy &amp;amp; Declining Use: SAML is no longer actively developed, and many modern applications are shifting towards OpenID Connect (OIDC).&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;📌 Example Use Case:&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;A company uses SAML-based SSO so employees can log in once and access tools like Google Workspace, Salesforce, and Slack without re-entering credentials.&lt;/p&gt;
&lt;hr&gt;
&lt;h4 id=&quot;2-oauth-2-0-borrowing-a-friend-s-car-authorization-not-authentication-&quot;&gt;2️⃣ OAuth 2.0 – Borrowing a Friend’s Car (Authorization, Not Authentication)&lt;/h4&gt;
&lt;p&gt;📖 Story: The Car Rental &amp;amp; Borrowing Keys&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://dev-to-uploads.s3.amazonaws.com/uploads/articles/54bywcfwhl5dxjgwpfy4.jpg&quot; alt=&quot;OAuth 2.0, Rental car analogy&quot;&gt;&lt;/p&gt;
&lt;p&gt;Imagine you need a rental car 🚗 (access to an app), but instead of getting a car yourself, you ask your friend to get it for you.&lt;/p&gt;
&lt;h5 id=&quot;how-it-works-oauth-flow-&quot;&gt;How It Works (OAuth Flow):&lt;/h5&gt;
&lt;p&gt;&lt;strong&gt;1️⃣ 🧍♂️ User (You) asks your Friend (Client App) to get a Car (Protected Resource).&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;You don’t have direct access to the rental car system.&lt;/p&gt;
&lt;p&gt;Instead, you give your friend (third-party app) permission to get a car for you.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;2️⃣ 🏛 Rental Agency (Authorization Server) issues a Car Key (Access Token).&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;The agency checks if your friend is authorized.&lt;/p&gt;
&lt;p&gt;They issue a temporary key (OAuth token) that allows your friend to drive the car.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;3️⃣ 🚗 Using the Car (Accessing Resources).&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Your friend shows the car key and gets the car without needing your ID.&lt;/p&gt;
&lt;p&gt;They can’t modify the rental agreement—just drive the car based on the given permission.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Key Takeaways:&lt;/strong&gt;&lt;br&gt;
✅ Authorization, Not Authentication: The car key doesn’t prove who you are, it just lets someone use a resource.&lt;br&gt;
✅ Token-Based: If someone steals the key, they can use the car, even if they aren’t you (Bearer Token Risk).&lt;br&gt;
✅ Used for API Access: Perfect for granting limited access to apps (e.g., &quot;Allow this app to access your Google Drive&quot;).&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;🚨 Key Misconception:&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;OAuth ≠ Authentication. It’s about authorization (permissions), not logging in.&lt;/p&gt;
&lt;hr&gt;
&lt;h4 id=&quot;3-openid-connect-oidc-vip-pass-with-your-photo-authentication-authorization-&quot;&gt;3️⃣ OpenID Connect (OIDC) – VIP Pass with Your Photo (Authentication + Authorization)&lt;/h4&gt;
&lt;p&gt;📖 Story: The All-Access Digital ID&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://dev-to-uploads.s3.amazonaws.com/uploads/articles/4bylvv14qx24qtazk7w0.jpg&quot; alt=&quot;OIDC, where a visitor uses a special wristband to enter an amusement park and access rides, symbolizing both authentication and authorization.&quot;&gt;&lt;/p&gt;
&lt;p&gt;Now, imagine a more modern amusement park where you can:&lt;br&gt;
✔ Use a digital ID 📱 instead of a paper ticket.&lt;br&gt;
✔ Your ID has your photo to prove who you are (Authentication).&lt;br&gt;
✔ Your ID also includes ride permissions (Authorization).&lt;/p&gt;
&lt;h5 id=&quot;how-it-works-oidc-flow-&quot;&gt;How It Works (OIDC Flow):&lt;/h5&gt;
&lt;p&gt;&lt;strong&gt;1️⃣ 🏛 Ticket Booth (Identity Provider) issues an ID with your photo.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;The booth verifies your identity and gives you a digital ID (ID Token) with your photo and details.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;2️⃣ 🎢 Amusement Park Scanners (Apps &amp;amp; APIs) accept the ID.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;When you go to a ride, you scan your digital ID.&lt;/p&gt;
&lt;p&gt;The system verifies your photo (Authentication) and checks ride access (Authorization).&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Key Takeaways:&lt;/strong&gt;&lt;br&gt;
✅ Authentication + Authorization: OIDC proves who you are (ID Token) + controls what you can do (Access Token).&lt;br&gt;
✅ Modern &amp;amp; Lightweight: Uses JSON instead of XML (simpler than SAML).&lt;br&gt;
✅ Best for Web &amp;amp; Mobile Apps: Google, Facebook, and Microsoft use OIDC for &quot;Sign in with Google/Facebook&quot; logins.&lt;br&gt;
✅ Replacing SAML: Many modern applications are shifting from SAML to OIDC for better support in web &amp;amp; mobile apps.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;-when-to-use-which-protocol-&quot;&gt;&lt;strong&gt;🤔 When to Use Which Protocol?&lt;/strong&gt;&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;&lt;strong&gt;Use Case&lt;/strong&gt; &lt;/th&gt;&lt;th&gt;&lt;strong&gt;Best Choice&lt;/strong&gt; &lt;/th&gt;&lt;th&gt;&lt;strong&gt;Why?&lt;/strong&gt;&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Enterprise SSO (Internal Apps)&lt;/strong&gt; &lt;/td&gt;&lt;td&gt;SAML &lt;/td&gt;&lt;td&gt;Secure, well-established in enterprises&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Third-party API Access&lt;/strong&gt; &lt;/td&gt;&lt;td&gt;OAuth 2.0 &lt;/td&gt;&lt;td&gt;Grants limited access without credentials&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Social Login &amp;amp; Modern Web Apps&lt;/strong&gt; &lt;/td&gt;&lt;td&gt;OIDC &lt;/td&gt;&lt;td&gt;Lightweight, authentication + authorization&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;hr&gt;
&lt;h3 id=&quot;-final-thoughts&quot;&gt;🎯 Final Thoughts&lt;/h3&gt;
&lt;p&gt;Understanding &lt;strong&gt;SAML&lt;/strong&gt;, &lt;strong&gt;OAuth&lt;/strong&gt; and &lt;strong&gt;OIDC&lt;/strong&gt; is crucial for securing applications. These protocols ensure users and apps interact securely without sharing sensitive credentials.&lt;/p&gt;
&lt;p&gt;You might have noticed that SAML and OIDC seem similar—both handle authentication and enable Single Sign-On (SSO). However, the &lt;strong&gt;key difference&lt;/strong&gt; is how they work:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;✔ SAML:&lt;/strong&gt; Uses XML, is enterprise-focused, and is mainly for web-based applications.&lt;br&gt;
&lt;strong&gt;✔ OIDC:&lt;/strong&gt; Uses JSON, is lightweight, and works well for modern web and mobile apps.&lt;/p&gt;
&lt;p&gt;While SAML is older and widely used in enterprises, OIDC is the future—it’s more flexible and designed for modern applications.&lt;/p&gt;
&lt;p&gt;🚀 If your system already uses SAML, you might stick with it. But if you're building something new, OIDC is likely the better choice!&lt;/p&gt;
&lt;p&gt;💬 Which protocol have you worked with the most? Let’s discuss in the comments! 🚀&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/28633/understanding-saml-oauth-2-0-and-openid-connect</guid>
<pubDate>Fri, 25 Sep 2026 16:02:35 +0000</pubDate>
</item>
<item>
<title>Building a Human-in-the-Loop GitHub Profile Automation with MyZubster and Zorgax</title>
<link>https://coderlegion.com/28491/building-a-human-in-the-loop-github-profile-automation-with-myzubster-and-zorgax</link>
<description>&lt;p&gt;thorization with the user.&lt;/p&gt;
&lt;p&gt;Result&lt;/p&gt;
&lt;p&gt;The full GitHub profile automation loop is now working:&lt;/p&gt;
&lt;p&gt;MyZubster&lt;br&gt;
→ Zorgax&lt;br&gt;
→ GitHub analysis&lt;br&gt;
→ generated README&lt;br&gt;
→ final preview&lt;br&gt;
→ explicit human approval&lt;br&gt;
→ authenticated GitHub write&lt;br&gt;
→ public commit&lt;/p&gt;
&lt;p&gt;It took more than generating Markdown.&lt;/p&gt;
&lt;p&gt;The difficult parts were identity, OAuth, state handoff, parsing, permission boundaries and making the final action auditable.&lt;/p&gt;
&lt;p&gt;And those are exactly the parts that make AI automation interesting to build.&lt;/p&gt;
&lt;p&gt;Project: MyZubster&lt;br&gt;
AI assistant: Zorgax&lt;br&gt;
Main repository: MyZubster-Ecosystem/myzubster&lt;br&gt;
Test profile: @myzubster&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/28491/building-a-human-in-the-loop-github-profile-automation-with-myzubster-and-zorgax</guid>
<pubDate>Thu, 24 Sep 2026 14:25:14 +0000</pubDate>
</item>
<item>
<title>Looking for 3 Beta Testers – HubSpot OAuth Installation Flow (Marketplace Submission)</title>
<link>https://coderlegion.com/23451/looking-for-3-beta-testers-hubspot-oauth-installation-flow-marketplace-submission</link>
<description>&lt;p&gt;We're preparing our HubSpot app for Marketplace submission and are looking for 3 developers or HubSpot users to help validate our OAuth installation flow.&lt;/p&gt;
&lt;p&gt;The goal is to ensure a smooth, secure, and reliable installation experience before the app goes through Marketplace review.&lt;/p&gt;
&lt;p&gt;What we're testing&lt;/p&gt;
&lt;p&gt;✅ HubSpot OAuth 2.0 authorization&lt;br&gt;
✅ Secure account connection&lt;br&gt;
✅ Token synchronization&lt;br&gt;
✅ Installation experience&lt;br&gt;
✅ Overall usability&lt;/p&gt;
&lt;p&gt;Who can help?&lt;/p&gt;
&lt;p&gt;HubSpot users&lt;br&gt;
SaaS founders&lt;br&gt;
CRM developers&lt;br&gt;
API developers&lt;br&gt;
Automation builders&lt;/p&gt;
&lt;p&gt;Installation takes about a minute. If you encounter any issues or have suggestions for improving the user experience, I'd genuinely appreciate your feedback.&lt;/p&gt;
&lt;p&gt;The installation link is available in the first comment.&lt;/p&gt;
&lt;p&gt;Thank you for helping improve the integration before Marketplace submission.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/23451/looking-for-3-beta-testers-hubspot-oauth-installation-flow-marketplace-submission</guid>
<pubDate>Sat, 25 Jul 2026 16:46:05 +0000</pubDate>
</item>
<item>
<title>SAML vs OIDC vs OAuth 2.0: 12 Differences Every B2B Engineering Team Should Know</title>
<link>https://coderlegion.com/15540/saml-vs-oidc-vs-oauth-2-0-12-differences-every-b2b-engineering-team-should-know</link>
<description>
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Quick answer is OAuth 2.0 is an authorization framework&lt;/strong&gt; (delegated access to resources). &lt;strong&gt;OIDC is an authentication layer built on top of OAuth 2.0&lt;/strong&gt; (modern identity for web, mobile, and SPAs). &lt;strong&gt;SAML 2.0 is a separate, XML-based enterprise SSO standard&lt;/strong&gt; from 2005. They are not competitors &amp;mdash; a production B2B SaaS will end up supporting all three. This article breaks down the 12 technical differences that actually matter when you are shipping enterprise-ready auth, plus a decision matrix and migration path. If you want to skip the 18-month build, &lt;a rel=&quot;nofollow&quot; href=&quot;https://ssojet.com/&quot;&gt;SSOJet&lt;/a&gt; is a protocol-agnostic broker that handles all three behind a single API.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Most &quot;SAML vs OIDC vs OAuth&quot; explainers treat the three as interchangeable login protocols. They are not. They were designed in different decades, by different standards bodies, to solve different problems &amp;mdash; and the differences show up in very real ways once you have paying enterprise customers.&lt;/p&gt;
&lt;p&gt;This is the engineer's guide- 12 dimensions, one decision matrix, one migration playbook, and an honest take on when to build versus buy.&lt;/p&gt;
&lt;h2&gt;The 30-Second Mental Model&lt;/h2&gt;
&lt;p&gt;Before the deep dive, lock in the mnemonic that makes these protocols stop blurring together:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;OAuth 2.0 =&lt;/strong&gt; AuthZ. A valet key that lets one app access resources in another app, on behalf of a user, without sharing the password.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;OIDC =&lt;/strong&gt; AuthN on top of OAuth. Adds a signed ID Token (JWT) so the app knows &lt;em&gt;who&lt;/em&gt; the user is.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;SAML 2.0 =&lt;/strong&gt; AuthN for enterprises. XML-based, browser-driven, older, and the lingua franca of corporate IT.&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;AuthN = who you are.&lt;/strong&gt; SAML and OIDC do this. &lt;strong&gt;AuthZ = what you can do.&lt;/strong&gt; OAuth 2.0 does this.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Every other difference in this article is a consequence of those three design goals.&lt;/p&gt;
&lt;h2&gt;Protocol Snapshot Table&lt;/h2&gt;
&lt;table class=&quot;border-collapse border border-muted&quot; style=&quot;min-width: 100px;&quot;&gt;&lt;colgroup&gt;&lt;col style=&quot;min-width: 25px;&quot;&gt;&lt;col style=&quot;min-width: 25px;&quot;&gt;&lt;col style=&quot;min-width: 25px;&quot;&gt;&lt;col style=&quot;min-width: 25px;&quot;&gt;&lt;/colgroup&gt;&lt;tbody&gt;&lt;tr class=&quot;border-b border-muted&quot;&gt;&lt;th class=&quot;border border-muted bg-muted px-2 py-1 text-left font-medium&quot; colspan=&quot;1&quot; rowspan=&quot;1&quot;&gt;&lt;p&gt;Dimension&lt;/p&gt;&lt;/th&gt;&lt;th class=&quot;border border-muted bg-muted px-2 py-1 text-left font-medium&quot; colspan=&quot;1&quot; rowspan=&quot;1&quot;&gt;&lt;p&gt;SAML 2.0&lt;/p&gt;&lt;/th&gt;&lt;th class=&quot;border border-muted bg-muted px-2 py-1 text-left font-medium&quot; colspan=&quot;1&quot; rowspan=&quot;1&quot;&gt;&lt;p&gt;OIDC&lt;/p&gt;&lt;/th&gt;&lt;th class=&quot;border border-muted bg-muted px-2 py-1 text-left font-medium&quot; colspan=&quot;1&quot; rowspan=&quot;1&quot;&gt;&lt;p&gt;OAuth 2.0&lt;/p&gt;&lt;/th&gt;&lt;/tr&gt;&lt;tr class=&quot;border-b border-muted&quot;&gt;&lt;td class=&quot;border border-muted px-2 py-1&quot; colspan=&quot;1&quot; rowspan=&quot;1&quot;&gt;&lt;p&gt;Primary purpose&lt;/p&gt;&lt;/td&gt;&lt;td class=&quot;border border-muted px-2 py-1&quot; colspan=&quot;1&quot; rowspan=&quot;1&quot;&gt;&lt;p&gt;Enterprise SSO &amp;amp; federation&lt;/p&gt;&lt;/td&gt;&lt;td class=&quot;border border-muted px-2 py-1&quot; colspan=&quot;1&quot; rowspan=&quot;1&quot;&gt;&lt;p&gt;User authentication&lt;/p&gt;&lt;/td&gt;&lt;td class=&quot;border border-muted px-2 py-1&quot; colspan=&quot;1&quot; rowspan=&quot;1&quot;&gt;&lt;p&gt;Delegated authorization&lt;/p&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr class=&quot;border-b border-muted&quot;&gt;&lt;td class=&quot;border border-muted px-2 py-1&quot; colspan=&quot;1&quot; rowspan=&quot;1&quot;&gt;&lt;p&gt;Standards body&lt;/p&gt;&lt;/td&gt;&lt;td class=&quot;border border-muted px-2 py-1&quot; colspan=&quot;1&quot; rowspan=&quot;1&quot;&gt;&lt;p&gt;OASIS&lt;/p&gt;&lt;/td&gt;&lt;td class=&quot;border border-muted px-2 py-1&quot; colspan=&quot;1&quot; rowspan=&quot;1&quot;&gt;&lt;p&gt;OpenID Foundation&lt;/p&gt;&lt;/td&gt;&lt;td class=&quot;border border-muted px-2 py-1&quot; colspan=&quot;1&quot; rowspan=&quot;1&quot;&gt;&lt;p&gt;IETF (RFC 6749)&lt;/p&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr class=&quot;border-b border-muted&quot;&gt;&lt;td class=&quot;border border-muted px-2 py-1&quot; colspan=&quot;1&quot; rowspan=&quot;1&quot;&gt;&lt;p&gt;Year standardized&lt;/p&gt;&lt;/td&gt;&lt;td class=&quot;border border-muted px-2 py-1&quot; colspan=&quot;1&quot; rowspan=&quot;1&quot;&gt;&lt;p&gt;2005&lt;/p&gt;&lt;/td&gt;&lt;td class=&quot;border border-muted px-2 py-1&quot; colspan=&quot;1&quot; rowspan=&quot;1&quot;&gt;&lt;p&gt;2014&lt;/p&gt;&lt;/td&gt;&lt;td class=&quot;border border-muted px-2 py-1&quot; colspan=&quot;1&quot; rowspan=&quot;1&quot;&gt;&lt;p&gt;2012&lt;/p&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr class=&quot;border-b border-muted&quot;&gt;&lt;td class=&quot;border border-muted px-2 py-1&quot; colspan=&quot;1&quot; rowspan=&quot;1&quot;&gt;&lt;p&gt;Token format&lt;/p&gt;&lt;/td&gt;&lt;td class=&quot;border border-muted px-2 py-1&quot; colspan=&quot;1&quot; rowspan=&quot;1&quot;&gt;&lt;p&gt;XML assertion&lt;/p&gt;&lt;/td&gt;&lt;td class=&quot;border border-muted px-2 py-1&quot; colspan=&quot;1&quot; rowspan=&quot;1&quot;&gt;&lt;p&gt;JWT (ID Token)&lt;/p&gt;&lt;/td&gt;&lt;td class=&quot;border border-muted px-2 py-1&quot; colspan=&quot;1&quot; rowspan=&quot;1&quot;&gt;&lt;p&gt;Opaque or JWT&lt;/p&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr class=&quot;border-b border-muted&quot;&gt;&lt;td class=&quot;border border-muted px-2 py-1&quot; colspan=&quot;1&quot; rowspan=&quot;1&quot;&gt;&lt;p&gt;Cryptography&lt;/p&gt;&lt;/td&gt;&lt;td class=&quot;border border-muted px-2 py-1&quot; colspan=&quot;1&quot; rowspan=&quot;1&quot;&gt;&lt;p&gt;XML-DSig / XML-Enc&lt;/p&gt;&lt;/td&gt;&lt;td class=&quot;border border-muted px-2 py-1&quot; colspan=&quot;1&quot; rowspan=&quot;1&quot;&gt;&lt;p&gt;JOSE (JWS / JWE)&lt;/p&gt;&lt;/td&gt;&lt;td class=&quot;border border-muted px-2 py-1&quot; colspan=&quot;1&quot; rowspan=&quot;1&quot;&gt;&lt;p&gt;JOSE when JWTs used&lt;/p&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr class=&quot;border-b border-muted&quot;&gt;&lt;td class=&quot;border border-muted px-2 py-1&quot; colspan=&quot;1&quot; rowspan=&quot;1&quot;&gt;&lt;p&gt;Transport&lt;/p&gt;&lt;/td&gt;&lt;td class=&quot;border border-muted px-2 py-1&quot; colspan=&quot;1&quot; rowspan=&quot;1&quot;&gt;&lt;p&gt;Browser redirects&lt;/p&gt;&lt;/td&gt;&lt;td class=&quot;border border-muted px-2 py-1&quot; colspan=&quot;1&quot; rowspan=&quot;1&quot;&gt;&lt;p&gt;REST + JSON&lt;/p&gt;&lt;/td&gt;&lt;td class=&quot;border border-muted px-2 py-1&quot; colspan=&quot;1&quot; rowspan=&quot;1&quot;&gt;&lt;p&gt;REST + JSON&lt;/p&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr class=&quot;border-b border-muted&quot;&gt;&lt;td class=&quot;border border-muted px-2 py-1&quot; colspan=&quot;1&quot; rowspan=&quot;1&quot;&gt;&lt;p&gt;Mobile / native fit&lt;/p&gt;&lt;/td&gt;&lt;td class=&quot;border border-muted px-2 py-1&quot; colspan=&quot;1&quot; rowspan=&quot;1&quot;&gt;&lt;p&gt;Poor&lt;/p&gt;&lt;/td&gt;&lt;td class=&quot;border border-muted px-2 py-1&quot; colspan=&quot;1&quot; rowspan=&quot;1&quot;&gt;&lt;p&gt;Excellent&lt;/p&gt;&lt;/td&gt;&lt;td class=&quot;border border-muted px-2 py-1&quot; colspan=&quot;1&quot; rowspan=&quot;1&quot;&gt;&lt;p&gt;Excellent&lt;/p&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr class=&quot;border-b border-muted&quot;&gt;&lt;td class=&quot;border border-muted px-2 py-1&quot; colspan=&quot;1&quot; rowspan=&quot;1&quot;&gt;&lt;p&gt;Discovery&lt;/p&gt;&lt;/td&gt;&lt;td class=&quot;border border-muted px-2 py-1&quot; colspan=&quot;1&quot; rowspan=&quot;1&quot;&gt;&lt;p&gt;Static XML metadata&lt;/p&gt;&lt;/td&gt;&lt;td class=&quot;border border-muted px-2 py-1&quot; colspan=&quot;1&quot; rowspan=&quot;1&quot;&gt;&lt;p&gt;&lt;code class=&quot;rounded-md bg-muted  px-1.5 py-1 font-mono font-medium&quot; spellcheck=&quot;false&quot;&gt;/.well-known/openid-configuration&lt;/code&gt; + JWKS&lt;/p&gt;&lt;/td&gt;&lt;td class=&quot;border border-muted px-2 py-1&quot; colspan=&quot;1&quot; rowspan=&quot;1&quot;&gt;&lt;p&gt;Via OIDC discovery&lt;/p&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr class=&quot;border-b border-muted&quot;&gt;&lt;td class=&quot;border border-muted px-2 py-1&quot; colspan=&quot;1&quot; rowspan=&quot;1&quot;&gt;&lt;p&gt;Logout&lt;/p&gt;&lt;/td&gt;&lt;td class=&quot;border border-muted px-2 py-1&quot; colspan=&quot;1&quot; rowspan=&quot;1&quot;&gt;&lt;p&gt;Single Logout (SLO) spec&lt;/p&gt;&lt;/td&gt;&lt;td class=&quot;border border-muted px-2 py-1&quot; colspan=&quot;1&quot; rowspan=&quot;1&quot;&gt;&lt;p&gt;Front/back-channel logout&lt;/p&gt;&lt;/td&gt;&lt;td class=&quot;border border-muted px-2 py-1&quot; colspan=&quot;1&quot; rowspan=&quot;1&quot;&gt;&lt;p&gt;Token revocation only&lt;/p&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr class=&quot;border-b border-muted&quot;&gt;&lt;td class=&quot;border border-muted px-2 py-1&quot; colspan=&quot;1&quot; rowspan=&quot;1&quot;&gt;&lt;p&gt;Provisioning&lt;/p&gt;&lt;/td&gt;&lt;td class=&quot;border border-muted px-2 py-1&quot; colspan=&quot;1&quot; rowspan=&quot;1&quot;&gt;&lt;p&gt;Usually paired with SCIM&lt;/p&gt;&lt;/td&gt;&lt;td class=&quot;border border-muted px-2 py-1&quot; colspan=&quot;1&quot; rowspan=&quot;1&quot;&gt;&lt;p&gt;Usually paired with SCIM&lt;/p&gt;&lt;/td&gt;&lt;td class=&quot;border border-muted px-2 py-1&quot; colspan=&quot;1&quot; rowspan=&quot;1&quot;&gt;&lt;p&gt;N/A&lt;/p&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr class=&quot;border-b border-muted&quot;&gt;&lt;td class=&quot;border border-muted px-2 py-1&quot; colspan=&quot;1&quot; rowspan=&quot;1&quot;&gt;&lt;p&gt;Recommended flow (2026)&lt;/p&gt;&lt;/td&gt;&lt;td class=&quot;border border-muted px-2 py-1&quot; colspan=&quot;1&quot; rowspan=&quot;1&quot;&gt;&lt;p&gt;Web Browser SSO Profile&lt;/p&gt;&lt;/td&gt;&lt;td class=&quot;border border-muted px-2 py-1&quot; colspan=&quot;1&quot; rowspan=&quot;1&quot;&gt;&lt;p&gt;Auth Code + PKCE&lt;/p&gt;&lt;/td&gt;&lt;td class=&quot;border border-muted px-2 py-1&quot; colspan=&quot;1&quot; rowspan=&quot;1&quot;&gt;&lt;p&gt;Auth Code + PKCE / Client Credentials&lt;/p&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr class=&quot;border-b border-muted&quot;&gt;&lt;td class=&quot;border border-muted px-2 py-1&quot; colspan=&quot;1&quot; rowspan=&quot;1&quot;&gt;&lt;p&gt;Best for&lt;/p&gt;&lt;/td&gt;&lt;td class=&quot;border border-muted px-2 py-1&quot; colspan=&quot;1&quot; rowspan=&quot;1&quot;&gt;&lt;p&gt;Regulated enterprise SSO&lt;/p&gt;&lt;/td&gt;&lt;td class=&quot;border border-muted px-2 py-1&quot; colspan=&quot;1&quot; rowspan=&quot;1&quot;&gt;&lt;p&gt;Modern web / mobile auth&lt;/p&gt;&lt;/td&gt;&lt;td class=&quot;border border-muted px-2 py-1&quot; colspan=&quot;1&quot; rowspan=&quot;1&quot;&gt;&lt;p&gt;API authorization&lt;/p&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;

&lt;p&gt;Now let's unpack the 12 differences that actually drive architecture decisions.&lt;/p&gt;
&lt;h2&gt;Difference #1: Protocol Origins and Design Goals&lt;/h2&gt;
&lt;p&gt;Each protocol was built to solve a different problem in a different era. Forgetting that is the single biggest source of confusion.&lt;/p&gt;
&lt;p&gt;SAML 2.0 was ratified by OASIS in March 2005, consolidating earlier work from Shibboleth and the Liberty Alliance. The design goal is a metadata-driven, browser-based federation protocol for enterprises that needed to bridge on-prem Active Directory with external web apps. SAML assumes a pre-established trust relationship between two organizations &amp;mdash; IT admins exchange signed XML metadata files, not API keys.&lt;/p&gt;
&lt;p&gt;OAuth 2.0 was published as RFC 6749 by the IETF in October 2012. Its goal was never authentication. It answers is &lt;em&gt;&quot;How does App A let User U grant App B permission to access their data in App A, without App B ever seeing User U's password?&quot;&lt;/em&gt; Think &quot;Sign in with Google to let Calendly read your calendar.&quot; That delegation pattern is OAuth's entire reason for existing.&lt;/p&gt;
&lt;p&gt;OpenID Connect (OIDC) launched on February 26, 2014, published by the OpenID Foundation. Developers had been misusing OAuth 2.0 for login (because it was easier than SAML) &amp;mdash; and doing it badly, insecurely. OIDC standardized the pattern by adding a signed ID Token on top of OAuth 2.0, plus a &lt;code&gt;userinfo&lt;/code&gt; endpoint and standardized scopes like &lt;code&gt;openid&lt;/code&gt;, &lt;code&gt;profile&lt;/code&gt;, &lt;code&gt;email&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Why this matters: you cannot &quot;pick between&quot; SAML and OAuth. They do different things. The actual choice is SAML vs OIDC for authentication, and OAuth vs nothing for API authorization.&lt;/p&gt;
&lt;h2&gt;Difference #2: Token Format &amp;mdash; XML Assertions vs JWTs&lt;/h2&gt;
&lt;p&gt;This is the most visible, day-to-day difference for engineers.&lt;/p&gt;
&lt;p&gt;A SAML assertion is a signed XML document, typically 2&amp;ndash;8 KB (sometimes much larger with attribute statements and embedded certificates). Signing uses XML-DSig, encryption uses XML-Enc. If you have ever debugged XML canonicalization mismatches at 2 AM, you know why SAML has a reputation for brittleness.&lt;/p&gt;
&lt;p&gt;An OIDC ID Token is a JWT &amp;mdash; Base64URL-encoded JSON, typically a few hundred bytes to 1 KB, signed via JWS using the JOSE framework. Paste one into &lt;a rel=&quot;nofollow&quot; href=&quot;https://jwt.compile7.org/&quot;&gt;jwt decoder&lt;/a&gt; and it's human-readable.&lt;/p&gt;
&lt;p&gt;OAuth 2.0 access tokens can be either opaque strings (validated by introspection) or JWTs (validated locally). Modern deployments favor JWTs for performance.&lt;/p&gt;
&lt;table class=&quot;border-collapse border border-muted&quot; style=&quot;min-width: 75px;&quot;&gt;&lt;colgroup&gt;&lt;col style=&quot;min-width: 25px;&quot;&gt;&lt;col style=&quot;min-width: 25px;&quot;&gt;&lt;col style=&quot;min-width: 25px;&quot;&gt;&lt;/colgroup&gt;&lt;tbody&gt;&lt;tr class=&quot;border-b border-muted&quot;&gt;&lt;th class=&quot;border border-muted bg-muted px-2 py-1 text-left font-medium&quot; colspan=&quot;1&quot; rowspan=&quot;1&quot;&gt;&lt;p&gt;&lt;/p&gt;&lt;/th&gt;&lt;th class=&quot;border border-muted bg-muted px-2 py-1 text-left font-medium&quot; colspan=&quot;1&quot; rowspan=&quot;1&quot;&gt;&lt;p&gt;SAML assertion&lt;/p&gt;&lt;/th&gt;&lt;th class=&quot;border border-muted bg-muted px-2 py-1 text-left font-medium&quot; colspan=&quot;1&quot; rowspan=&quot;1&quot;&gt;&lt;p&gt;JWT (OIDC / OAuth)&lt;/p&gt;&lt;/th&gt;&lt;/tr&gt;&lt;tr class=&quot;border-b border-muted&quot;&gt;&lt;td class=&quot;border border-muted px-2 py-1&quot; colspan=&quot;1&quot; rowspan=&quot;1&quot;&gt;&lt;p&gt;Size&lt;/p&gt;&lt;/td&gt;&lt;td class=&quot;border border-muted px-2 py-1&quot; colspan=&quot;1&quot; rowspan=&quot;1&quot;&gt;&lt;p&gt;2&amp;ndash;8 KB typical&lt;/p&gt;&lt;/td&gt;&lt;td class=&quot;border border-muted px-2 py-1&quot; colspan=&quot;1&quot; rowspan=&quot;1&quot;&gt;&lt;p&gt;~300 bytes &amp;ndash; 1 KB&lt;/p&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr class=&quot;border-b border-muted&quot;&gt;&lt;td class=&quot;border border-muted px-2 py-1&quot; colspan=&quot;1&quot; rowspan=&quot;1&quot;&gt;&lt;p&gt;Format&lt;/p&gt;&lt;/td&gt;&lt;td class=&quot;border border-muted px-2 py-1&quot; colspan=&quot;1&quot; rowspan=&quot;1&quot;&gt;&lt;p&gt;XML&lt;/p&gt;&lt;/td&gt;&lt;td class=&quot;border border-muted px-2 py-1&quot; colspan=&quot;1&quot; rowspan=&quot;1&quot;&gt;&lt;p&gt;JSON&lt;/p&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr class=&quot;border-b border-muted&quot;&gt;&lt;td class=&quot;border border-muted px-2 py-1&quot; colspan=&quot;1&quot; rowspan=&quot;1&quot;&gt;&lt;p&gt;Parsing&lt;/p&gt;&lt;/td&gt;&lt;td class=&quot;border border-muted px-2 py-1&quot; colspan=&quot;1&quot; rowspan=&quot;1&quot;&gt;&lt;p&gt;XML parser + canonicalization&lt;/p&gt;&lt;/td&gt;&lt;td class=&quot;border border-muted px-2 py-1&quot; colspan=&quot;1&quot; rowspan=&quot;1&quot;&gt;&lt;p&gt;Base64 decode + JSON parse&lt;/p&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr class=&quot;border-b border-muted&quot;&gt;&lt;td class=&quot;border border-muted px-2 py-1&quot; colspan=&quot;1&quot; rowspan=&quot;1&quot;&gt;&lt;p&gt;Inspectable&lt;/p&gt;&lt;/td&gt;&lt;td class=&quot;border border-muted px-2 py-1&quot; colspan=&quot;1&quot; rowspan=&quot;1&quot;&gt;&lt;p&gt;Painful&lt;/p&gt;&lt;/td&gt;&lt;td class=&quot;border border-muted px-2 py-1&quot; colspan=&quot;1&quot; rowspan=&quot;1&quot;&gt;&lt;p&gt;One click at jwt decoder&lt;/p&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr class=&quot;border-b border-muted&quot;&gt;&lt;td class=&quot;border border-muted px-2 py-1&quot; colspan=&quot;1&quot; rowspan=&quot;1&quot;&gt;&lt;p&gt;Passable in header&lt;/p&gt;&lt;/td&gt;&lt;td class=&quot;border border-muted px-2 py-1&quot; colspan=&quot;1&quot; rowspan=&quot;1&quot;&gt;&lt;p&gt;No (too large)&lt;/p&gt;&lt;/td&gt;&lt;td class=&quot;border border-muted px-2 py-1&quot; colspan=&quot;1&quot; rowspan=&quot;1&quot;&gt;&lt;p&gt;Yes (&lt;code class=&quot;rounded-md bg-muted  px-1.5 py-1 font-mono font-medium&quot; spellcheck=&quot;false&quot;&gt;Authorization: Bearer&lt;/code&gt;)&lt;/p&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr class=&quot;border-b border-muted&quot;&gt;&lt;td class=&quot;border border-muted px-2 py-1&quot; colspan=&quot;1&quot; rowspan=&quot;1&quot;&gt;&lt;p&gt;Mobile / SPA friendly&lt;/p&gt;&lt;/td&gt;&lt;td class=&quot;border border-muted px-2 py-1&quot; colspan=&quot;1&quot; rowspan=&quot;1&quot;&gt;&lt;p&gt;No&lt;/p&gt;&lt;/td&gt;&lt;td class=&quot;border border-muted px-2 py-1&quot; colspan=&quot;1&quot; rowspan=&quot;1&quot;&gt;&lt;p&gt;Yes&lt;/p&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;

&lt;p&gt;&lt;strong&gt;Why this matters:&lt;/strong&gt; at scale, the CPU cost of parsing and canonicalizing XML adds real infrastructure bills. More importantly, the SAML attack surface (XML Signature Wrapping, XSLT injection, entity expansion) is deeper and scarier than the JWT attack surface &amp;mdash; though both require care.&lt;/p&gt;
&lt;h2&gt;Difference #3: Transport Assumptions &amp;mdash; Browser vs REST&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;SAML was designed for the browser.&lt;/strong&gt; The three bindings you will see in practice are:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;HTTP-Redirect&lt;/strong&gt; &amp;mdash; small requests passed via URL query string (typically the &lt;code&gt;AuthnRequest&lt;/code&gt;).&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;HTTP-POST&lt;/strong&gt; &amp;mdash; large responses (the actual assertion) passed via an auto-submitting HTML form.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;HTTP-Artifact&lt;/strong&gt; &amp;mdash; an opaque reference the SP redeems via back-channel SOAP.&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Every SAML SSO flow routes through the user's browser as the message courier.&lt;/p&gt;
&lt;p&gt;OIDC and OAuth 2.0 assume REST + JSON over HTTPS. The recommended flow in 2026 for every client type &amp;mdash; web apps, SPAs, mobile, desktop, CLIs &amp;mdash; is Authorization Code with PKCE (RFC 7636). For machine-to-machine, OAuth 2.0 offers the Client Credentials grant, which has no SAML equivalent.&lt;/p&gt;
&lt;p&gt;The practical consequence: if your product has a mobile app, a CLI, or any non-browser client, SAML alone is insufficient. This is why OIDC is &quot;mobile-native&quot; and SAML is not.&lt;/p&gt;
&lt;h2&gt;Difference #4: Discovery and Metadata Management&lt;/h2&gt;
&lt;p&gt;This is where SAML deployments quietly age badly.&lt;/p&gt;
&lt;p&gt;SAML discovery is static. Each entity publishes an &lt;code&gt;EntityDescriptor&lt;/code&gt; XML file listing its endpoints (SingleSignOnService, AssertionConsumerService, etc.), supported bindings, and &amp;mdash; critically &amp;mdash; X.509 certificates embedded inside &lt;code&gt;&amp;lt;KeyDescriptor&amp;gt;&lt;/code&gt; elements. This metadata is exchanged manually (often via email) or hosted at a stable URL the partner polls.&lt;/p&gt;
&lt;p&gt;Certificate rotation is where SAML breaks. When a signing cert is about to expire, you must publish the new cert inside the metadata &lt;em&gt;alongside&lt;/em&gt; the old one during an overlap window, and every downstream SP must refresh their cached copy. Miss that, and half your enterprise tenants lose SSO on the same Monday morning.&lt;/p&gt;
&lt;p&gt;OIDC discovery is dynamic. Providers publish a &lt;code&gt;/.well-known/openid-configuration&lt;/code&gt; JSON document, and inside is a &lt;code&gt;jwks_uri&lt;/code&gt; that points to a live JWKS (JSON Web Key Set) endpoint. Keys rotate by publishing a new key with a new &lt;code&gt;kid&lt;/code&gt;. Clients pick it up on the next token validation automatically. No emails. No coordination.&lt;/p&gt;
&lt;p&gt;For the full operational playbook on this topic, see our deep dive on &lt;a rel=&quot;nofollow&quot; href=&quot;https://ssojet.com/blog/&quot;&gt;enterprise federation and SAML metadata management&lt;/a&gt;.&lt;/p&gt;
&lt;h2&gt;Difference #5: Enterprise vs Consumer Fit&lt;/h2&gt;
&lt;p&gt;There is a reason regulated enterprises still demand SAML in 2026. It is not nostalgia &amp;mdash; it is procurement and compliance.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;SAML's strengths in enterprise:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;p&gt;20+ years of deployment inside ADFS, Ping, Shibboleth, Entra ID, Okta.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Embedded in federation networks like &lt;strong&gt;InCommon&lt;/strong&gt; and &lt;strong&gt;eduGAIN&lt;/strong&gt;.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Audited into FedRAMP, HIPAA, PCI-DSS, and banking sector compliance frameworks.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Rich attribute exchange &amp;mdash; roles, groups, department, employee ID &amp;mdash; all in a single assertion.&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;OIDC's strengths in modern environments:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;p&gt;Dominates CIAM (Customer Identity and Access Management).&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Native to every modern IdP -Entra ID, Google Workspace, Okta, Auth0.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;First-class SPA, mobile, and API support.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Developer-friendly SDKs in every major language.&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;The B2B SaaS reality:&lt;/strong&gt; if you are selling up-market, you will ship both. Mid-market customers will happily take OIDC. Enterprise customers will hand you a SAML metadata XML file on day one of procurement. This is the pattern our &lt;a rel=&quot;nofollow&quot; href=&quot;https://ssojet.com/blog/oidc-vs-saml&quot;&gt;SSO protocol primer: SAML, OIDC and OAuth explained&lt;/a&gt; walks through in detail.&lt;/p&gt;
&lt;h2&gt;Difference #6: Session Management and Logout&lt;/h2&gt;
&lt;p&gt;If there's one feature that &quot;works on the slide deck but breaks in production,&quot; it's Single Logout.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;SAML has a formal Single Logout (SLO) spec.&lt;/strong&gt; Each assertion carries a &lt;code&gt;SessionIndex&lt;/code&gt;, and logout flows through either:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Front-channel SLO&lt;/strong&gt; &amp;mdash; IdP redirects the user's browser through every SP's logout URL sequentially. Breaks the moment the user closes a tab, hits a popup blocker, or loses connectivity.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Back-channel SLO&lt;/strong&gt; &amp;mdash; IdP makes SOAP calls directly to each SP. More reliable, but requires network reachability that enterprise firewalls rarely grant.&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The SAML spec itself acknowledges &quot;partial logout&quot; as a valid outcome. That is the protocol telling you this is hard.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;OIDC defines three logout modes:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;RP-Initiated Logout&lt;/strong&gt; &amp;mdash; relying party redirects to the IdP's &lt;code&gt;end_session_endpoint&lt;/code&gt;.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Front-Channel Logout&lt;/strong&gt; &amp;mdash; IdP loads iframes for each connected client.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Back-Channel Logout&lt;/strong&gt; &amp;mdash; IdP sends signed logout tokens (JWTs) directly to each client.&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;None of these are bulletproof either, but they use modern transports and produce debuggable failures.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;OAuth 2.0 has no logout concept at all&lt;/strong&gt; &amp;mdash; only token revocation (RFC 7009). This is by design; OAuth is about access tokens, not sessions.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Honest advice:&lt;/strong&gt; do not promise customers &quot;one-click log out of everything everywhere.&quot; Implement &lt;/p&gt;
</description>
<category>Other</category>
<guid isPermaLink="true">https://coderlegion.com/15540/saml-vs-oidc-vs-oauth-2-0-12-differences-every-b2b-engineering-team-should-know</guid>
<pubDate>Tue, 21 Jul 2026 08:03:43 +0000</pubDate>
</item>
<item>
<title>Access vs Refresh Tokens: I Was Missing Two Ideas</title>
<link>https://coderlegion.com/22752/access-vs-refresh-tokens-i-was-missing-two-ideas</link>
<description>&lt;p&gt;Years ago I was building a login API, and a colleague looked at what I was returning and asked why I only had one token. He said the newer APIs hand back two, an access token and a refresh token. Mine returned one. So I asked him the obvious thing: why two? What does the second one buy you?&lt;/p&gt;
&lt;p&gt;He didn't really know either. He was a front-end dev, and he'd only ever seen the two tokens come back from the backend on a project he'd worked on. When he asked the person who built that side why there were two, all he got was &quot;it's industry standard.&quot; That was the whole explanation. So he was passing that straight down to me: two tokens, that's just how it's done. He said he'd dig into the real reason and get back to me. Two developers standing there, and the actual why had already gone missing one hop up the chain, buried under &quot;it's industry standard.&quot;&lt;/p&gt;
&lt;p&gt;So I did what a confused junior does when the fancy version doesn't make sense. I called it overhead and shipped the simple one. One token, a JWT, and because I didn't want to think about expiry I gave it a life of something like seven years. No revocation. Once it was signed and handed out, it was valid until roughly the next World Cup, and there was nothing I could do to kill it. The only reason that never blew up in my face is that the project never went to production. I didn't get smart, I got lucky.&lt;/p&gt;
&lt;h3 id=&quot;why-two-tokens-made-no-sense-to-me&quot;&gt;Why two tokens made no sense to me&lt;/h3&gt;
&lt;p&gt;The thing worth being honest about is exactly why I thought it was dumb, because the wrong mental model is the interesting part, not the right one.&lt;/p&gt;
&lt;p&gt;First, I thought the refresh token was just a longer-lived copy of the access token. Same thing, one lives longer. If they're the same thing, why carry two? Just use the long one.&lt;/p&gt;
&lt;p&gt;Then my colleague came back with the first real clue: the refresh token lives longer, and it's used rarely, only the access token gets sent on every request, so there's less chance of the refresh one getting stolen. And I remember pushing back on that, because &quot;less chance of being stolen&quot; sounded like a hand-wave. There's still a chance. If it can be stolen at all, and it's the long-lived one, then leaking it is worse, not better. That didn't sound like security, it sounded like hoping.&lt;/p&gt;
&lt;p&gt;And underneath both of those was a bigger assumption I didn't even know I was making: tokens are stateless. That's the whole selling point, right, the server doesn't store them, it just checks the signature. So the idea of revoking one made no sense to me. You can't reach out and kill a stateless token, there's nothing to kill. And if you did want that, we already have sessions for exactly that job, so why reinvent it worse.&lt;/p&gt;
&lt;p&gt;I also had never heard of scopes. It genuinely never occurred to me that a token could be restricted to one specific job. In my head a token meant &quot;this is me, let me do things,&quot; full stop. So when I stacked all of that up, two tokens looked like ceremony. Two things that do the same job, one just lives longer, neither one killable, for a benefit I couldn't measure.&lt;/p&gt;
&lt;h3 id=&quot;the-two-ideas-i-was-actually-missing&quot;&gt;The two ideas I was actually missing&lt;/h3&gt;
&lt;p&gt;None of this resolved quickly. For months after that conversation we just kept shipping the single-token approach, because it worked and we didn't know any better. We were a handful of junior devs with nobody above us to ask. We knew JWT was the standard tool for auth, so we used it the way we understood it, one token, and moved on. The question just sat there, unanswered, for months.&lt;/p&gt;
&lt;p&gt;It didn't click on the job. It clicked on YouTube, which is where I go when a thing refuses to make sense and I want to hear someone who actually gets it explain it.&lt;/p&gt;
&lt;p&gt;Two concepts landed in the same video and the whole thing fell over at once.&lt;/p&gt;
&lt;p&gt;The first was revocation. A token doesn't have to be a fire-and-forget thing you can never call back. If you store it, you can keep a list of the ones you've killed and refuse them. I'd been treating &quot;stateless&quot; as a law instead of a choice, and the moment it's a choice, killing a leaked token is just something you get to decide.&lt;/p&gt;
&lt;p&gt;The second was scopes. A token can be restricted to certain actions and nothing else. So the two tokens weren't two copies of the same authority after all. They could have different powers on purpose.&lt;/p&gt;
&lt;p&gt;Once I had those two pieces I ran every scenario in my head and it all just resolved, and honestly I started laughing at myself, because it was so easy. This was the same access-and-refresh split I'd bounced off when I first went reading about auth, back when none of the articles made sense. Nothing had changed. I'd just been missing two words. I went to my computer and built a small login, signup, and refresh flow as a demo, and I understood the entire thing end to end in under an hour.&lt;/p&gt;
&lt;h3 id=&quot;so-what-are-the-two-tokens-actually-for&quot;&gt;So what are the two tokens actually for&lt;/h3&gt;
&lt;p&gt;Here's the version I wish someone had handed me at the start.&lt;/p&gt;
&lt;p&gt;The access token is the one you send on every single request. Because it's everywhere, all the time, in headers, in logs if you're careless, on every hop, it's the one most likely to leak. So you make it cheap to lose. You give it a short life, minutes to an hour, and you scope it to just what it needs. It's a stateless JWT, so the server never looks it up, it just checks the signature and trusts it until it expires. That's what keeps it fast, and it's also why you don't try to kill an access token individually: you can't, so you lean on the short life instead. If a copy leaks, the damage is small and it ages out on its own fast.&lt;/p&gt;
&lt;p&gt;The refresh token has exactly one job: get a new access token. That's it. It can't read your data, it can't hit your endpoints, it does one thing. And here's the part I'd had backwards: the refresh token usually isn't a JWT at all. It's an opaque random string, a row in your database, and that's on purpose, because a row is a thing you can delete. That's what makes it revocable. It also only ever travels to one place, the single refresh endpoint, never your other routes, so its exposure is a sliver of what the access token's is. You send it rarely, only when the access token is about to run out, so there are fewer chances to leak it in the first place, and the moment you suspect it, you delete the row and it's dead on the next use.&lt;/p&gt;
&lt;p&gt;That finally answers the thing past-me couldn't get over: yes, the long-lived one can still be stolen, but it only works against that one refresh route, it's useless anywhere else, and it dies the instant you kill the row. The long life stops being scary once the thing is killable and can barely go anywhere.&lt;/p&gt;
&lt;p&gt;Where you keep it matters as much as any of this. The refresh token lives in a cryptographically signed, httpOnly cookie, which the browser will hand back only to your server and which JavaScript on the page can't read. What you never do is drop it in localStorage. Anything running on your page can read localStorage, and on a modern app that's a pile of npm packages you didn't write. One compromised dependency reads your token straight out of storage and walks off with it, and you won't get so much as a log line. A signed cookie closes that door.&lt;/p&gt;
&lt;p&gt;That's the piece I'd been missing. The refresh token is not a longer copy of the access token. It has a job the access token can't safely do. It keeps you logged in without making you re-type your password every hour, and it does that while staying killable. Short-lived thing you use constantly, long-lived thing you use almost never and can revoke on demand. Two different jobs, so two different tokens.&lt;/p&gt;
&lt;p&gt;And that split is the best-of-both trick I never saw coming. A session checks the database on every single request: safe, always current, but a lookup every time. A pure JWT checks nothing, ever: fast, but you can't call it back. Two tokens sit right between them. The access token is the no-lookup JWT on the hot path, so the requests that happen constantly stay fast, and the refresh token is the database-backed, revocable thing on the cold path that fires rarely. You get the speed of stateless where it's hot and the killability of sessions where it counts.&lt;/p&gt;
&lt;h3 id=&quot;the-honest-part-you-probably-don-t-need-it&quot;&gt;The honest part: you probably don't need it&lt;/h3&gt;
&lt;p&gt;Now that I get it, I'm not going to pretend everyone should reach for two tokens. Most apps don't need this.&lt;/p&gt;
&lt;p&gt;For a normal app with sessions, one token is fine. Give it a sane life, seven or thirty days, and when it expires, ask the user to log in again. That's not a security hole, that's just a login. The two-token dance has a real cost: you're now refreshing tokens on a timer, maintaining a blacklist, handling the refresh failing, handling the race where two requests refresh at once. That's more moving parts, and every moving part is a new place for it to break.&lt;/p&gt;
&lt;p&gt;Notice that my seven-year JWT wasn't dumb because it was one token. It was dumb because it was unbounded and I couldn't kill it. A single token with a bounded life and a re-login is a perfectly reasonable design for most things I'd build today. Two tokens is what you reach for when the blast radius is big enough to earn the extra machinery: a big multi-tenant system, anything moving real money, anything where a leaked credential is expensive to clean up.&lt;/p&gt;
&lt;p&gt;If I could hand one sentence back to the guy shipping that seven-year token, it'd be this. Short access, long refresh, only the refresh can mint new ones. If the small one leaks, the damage window is tiny. If the long one leaks, you blacklist it. That's the whole thing, and it took me two words, revocation and scopes, to see it.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/22752/access-vs-refresh-tokens-i-was-missing-two-ideas</guid>
<pubDate>Wed, 15 Jul 2026 12:11:13 +0000</pubDate>
</item>
<item>
<title>SAML vs OIDC vs OAuth: The 60-Second B2B Playbook</title>
<link>https://coderlegion.com/15542/saml-vs-oidc-vs-oauth-the-60-second-b2b-playbook</link>
<description>
&lt;h1&gt;SAML vs OIDC vs OAuth: The 60-Second B2B Playbook&lt;/h1&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR (read this first):&lt;/strong&gt; OAuth 2.0 is &lt;strong&gt;authorization&lt;/strong&gt; (a valet key for APIs). OIDC is &lt;strong&gt;authentication&lt;/strong&gt; built &lt;em&gt;on top of&lt;/em&gt; OAuth 2.0 (modern login for web, mobile, and SPAs). SAML 2.0 is a &lt;strong&gt;separate, XML-based protocol&lt;/strong&gt; designed for enterprise Single Sign-On. If you are building a B2B SaaS in 2026, you will end up shipping &lt;strong&gt;all three&lt;/strong&gt; &amp;mdash; not picking one. The only real question is whether you build that plumbing yourself or front-end it with an identity broker like &lt;a rel=&quot;nofollow&quot; href=&quot;https://ssojet.com/&quot;&gt;SSOJet&lt;/a&gt;.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Most &quot;SAML vs OIDC vs OAuth&quot; articles treat the three protocols as competitors. They are not. They solve different problems, were designed in different decades, and, in a real B2B product, they coexist.&lt;/p&gt;
&lt;p&gt;This playbook gives you the 60-second decision tree, the 12 differences that actually matter in production, and the migration path enterprise customers will eventually force you down.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;The 60-Second Decision Tree&lt;/h2&gt;
&lt;p&gt;Before we go deep, here is the decision your engineering team actually has to make. Read it out loud once and you will never confuse these protocols again.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Do you need to let a third-party app call an API on behalf of a user?&lt;/strong&gt; &amp;rarr; &lt;strong&gt;OAuth 2.0&lt;/strong&gt; (authorization).&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Do you need to know &lt;em&gt;who&lt;/em&gt; the user is so you can log them into your app?&lt;/strong&gt; &amp;rarr; &lt;strong&gt;OIDC&lt;/strong&gt; (authentication, built on OAuth 2.0).&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Is the customer a Fortune 500 buying your SaaS, and their IT team just emailed you an XML metadata file?&lt;/strong&gt; &amp;rarr; &lt;strong&gt;SAML 2.0&lt;/strong&gt; (enterprise SSO, non-negotiable).&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;All three of the above in the same product?&lt;/strong&gt; &amp;rarr; Welcome to B2B SaaS. You need a protocol-agnostic identity layer.&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;If you want a short mnemonic engineers actually remember: &lt;strong&gt;AuthN is SAML and OIDC. AuthZ is OAuth. OIDC is the one that speaks JSON.&lt;/strong&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;Quick-Answer Comparison Table&lt;/h2&gt;
&lt;table class=&quot;border-collapse border border-muted&quot; style=&quot;min-width: 100px;&quot;&gt;&lt;colgroup&gt;&lt;col style=&quot;min-width: 25px;&quot;&gt;&lt;col style=&quot;min-width: 25px;&quot;&gt;&lt;col style=&quot;min-width: 25px;&quot;&gt;&lt;col style=&quot;min-width: 25px;&quot;&gt;&lt;/colgroup&gt;&lt;tbody&gt;&lt;tr class=&quot;border-b border-muted&quot;&gt;&lt;th class=&quot;border border-muted bg-muted px-2 py-1 text-left font-medium&quot; colspan=&quot;1&quot; rowspan=&quot;1&quot;&gt;&lt;p&gt;Dimension&lt;/p&gt;&lt;/th&gt;&lt;th class=&quot;border border-muted bg-muted px-2 py-1 text-left font-medium&quot; colspan=&quot;1&quot; rowspan=&quot;1&quot;&gt;&lt;p&gt;SAML 2.0&lt;/p&gt;&lt;/th&gt;&lt;th class=&quot;border border-muted bg-muted px-2 py-1 text-left font-medium&quot; colspan=&quot;1&quot; rowspan=&quot;1&quot;&gt;&lt;p&gt;OIDC&lt;/p&gt;&lt;/th&gt;&lt;th class=&quot;border border-muted bg-muted px-2 py-1 text-left font-medium&quot; colspan=&quot;1&quot; rowspan=&quot;1&quot;&gt;&lt;p&gt;OAuth 2.0&lt;/p&gt;&lt;/th&gt;&lt;/tr&gt;&lt;tr class=&quot;border-b border-muted&quot;&gt;&lt;td class=&quot;border border-muted px-2 py-1&quot; colspan=&quot;1&quot; rowspan=&quot;1&quot;&gt;&lt;p&gt;&lt;strong&gt;Purpose&lt;/strong&gt;&lt;/p&gt;&lt;/td&gt;&lt;td class=&quot;border border-muted px-2 py-1&quot; colspan=&quot;1&quot; rowspan=&quot;1&quot;&gt;&lt;p&gt;Enterprise SSO / federation&lt;/p&gt;&lt;/td&gt;&lt;td class=&quot;border border-muted px-2 py-1&quot; colspan=&quot;1&quot; rowspan=&quot;1&quot;&gt;&lt;p&gt;User authentication&lt;/p&gt;&lt;/td&gt;&lt;td class=&quot;border border-muted px-2 py-1&quot; colspan=&quot;1&quot; rowspan=&quot;1&quot;&gt;&lt;p&gt;Delegated authorization&lt;/p&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr class=&quot;border-b border-muted&quot;&gt;&lt;td class=&quot;border border-muted px-2 py-1&quot; colspan=&quot;1&quot; rowspan=&quot;1&quot;&gt;&lt;p&gt;&lt;strong&gt;Year standardized&lt;/strong&gt;&lt;/p&gt;&lt;/td&gt;&lt;td class=&quot;border border-muted px-2 py-1&quot; colspan=&quot;1&quot; rowspan=&quot;1&quot;&gt;&lt;p&gt;2005 (OASIS)&lt;/p&gt;&lt;/td&gt;&lt;td class=&quot;border border-muted px-2 py-1&quot; colspan=&quot;1&quot; rowspan=&quot;1&quot;&gt;&lt;p&gt;2014 (OpenID Foundation)&lt;/p&gt;&lt;/td&gt;&lt;td class=&quot;border border-muted px-2 py-1&quot; colspan=&quot;1&quot; rowspan=&quot;1&quot;&gt;&lt;p&gt;2012 (IETF, RFC 6749)&lt;/p&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr class=&quot;border-b border-muted&quot;&gt;&lt;td class=&quot;border border-muted px-2 py-1&quot; colspan=&quot;1&quot; rowspan=&quot;1&quot;&gt;&lt;p&gt;&lt;strong&gt;Token format&lt;/strong&gt;&lt;/p&gt;&lt;/td&gt;&lt;td class=&quot;border border-muted px-2 py-1&quot; colspan=&quot;1&quot; rowspan=&quot;1&quot;&gt;&lt;p&gt;XML assertions&lt;/p&gt;&lt;/td&gt;&lt;td class=&quot;border border-muted px-2 py-1&quot; colspan=&quot;1&quot; rowspan=&quot;1&quot;&gt;&lt;p&gt;JSON Web Tokens (JWT)&lt;/p&gt;&lt;/td&gt;&lt;td class=&quot;border border-muted px-2 py-1&quot; colspan=&quot;1&quot; rowspan=&quot;1&quot;&gt;&lt;p&gt;Opaque strings or JWTs&lt;/p&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr class=&quot;border-b border-muted&quot;&gt;&lt;td class=&quot;border border-muted px-2 py-1&quot; colspan=&quot;1&quot; rowspan=&quot;1&quot;&gt;&lt;p&gt;&lt;strong&gt;Message signing&lt;/strong&gt;&lt;/p&gt;&lt;/td&gt;&lt;td class=&quot;border border-muted px-2 py-1&quot; colspan=&quot;1&quot; rowspan=&quot;1&quot;&gt;&lt;p&gt;XML-DSig / XML-Enc&lt;/p&gt;&lt;/td&gt;&lt;td class=&quot;border border-muted px-2 py-1&quot; colspan=&quot;1&quot; rowspan=&quot;1&quot;&gt;&lt;p&gt;JOSE (JWS / JWE)&lt;/p&gt;&lt;/td&gt;&lt;td class=&quot;border border-muted px-2 py-1&quot; colspan=&quot;1&quot; rowspan=&quot;1&quot;&gt;&lt;p&gt;JOSE when JWTs are used&lt;/p&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr class=&quot;border-b border-muted&quot;&gt;&lt;td class=&quot;border border-muted px-2 py-1&quot; colspan=&quot;1&quot; rowspan=&quot;1&quot;&gt;&lt;p&gt;&lt;strong&gt;Transport&lt;/strong&gt;&lt;/p&gt;&lt;/td&gt;&lt;td class=&quot;border border-muted px-2 py-1&quot; colspan=&quot;1&quot; rowspan=&quot;1&quot;&gt;&lt;p&gt;Browser redirects (HTTP-POST / Redirect / Artifact)&lt;/p&gt;&lt;/td&gt;&lt;td class=&quot;border border-muted px-2 py-1&quot; colspan=&quot;1&quot; rowspan=&quot;1&quot;&gt;&lt;p&gt;REST + JSON over HTTPS&lt;/p&gt;&lt;/td&gt;&lt;td class=&quot;border border-muted px-2 py-1&quot; colspan=&quot;1&quot; rowspan=&quot;1&quot;&gt;&lt;p&gt;REST + JSON over HTTPS&lt;/p&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr class=&quot;border-b border-muted&quot;&gt;&lt;td class=&quot;border border-muted px-2 py-1&quot; colspan=&quot;1&quot; rowspan=&quot;1&quot;&gt;&lt;p&gt;&lt;strong&gt;Discovery&lt;/strong&gt;&lt;/p&gt;&lt;/td&gt;&lt;td class=&quot;border border-muted px-2 py-1&quot; colspan=&quot;1&quot; rowspan=&quot;1&quot;&gt;&lt;p&gt;Static XML metadata file&lt;/p&gt;&lt;/td&gt;&lt;td class=&quot;border border-muted px-2 py-1&quot; colspan=&quot;1&quot; rowspan=&quot;1&quot;&gt;&lt;p&gt;&lt;code class=&quot;rounded-md bg-muted  px-1.5 py-1 font-mono font-medium&quot; spellcheck=&quot;false&quot;&gt;/.well-known/openid-configuration&lt;/code&gt; + JWKS URI&lt;/p&gt;&lt;/td&gt;&lt;td class=&quot;border border-muted px-2 py-1&quot; colspan=&quot;1&quot; rowspan=&quot;1&quot;&gt;&lt;p&gt;Manual or via OIDC discovery&lt;/p&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr class=&quot;border-b border-muted&quot;&gt;&lt;td class=&quot;border border-muted px-2 py-1&quot; colspan=&quot;1&quot; rowspan=&quot;1&quot;&gt;&lt;p&gt;&lt;strong&gt;Mobile / native app fit&lt;/strong&gt;&lt;/p&gt;&lt;/td&gt;&lt;td class=&quot;border border-muted px-2 py-1&quot; colspan=&quot;1&quot; rowspan=&quot;1&quot;&gt;&lt;p&gt;Poor (designed for browsers)&lt;/p&gt;&lt;/td&gt;&lt;td class=&quot;border border-muted px-2 py-1&quot; colspan=&quot;1&quot; rowspan=&quot;1&quot;&gt;&lt;p&gt;Excellent&lt;/p&gt;&lt;/td&gt;&lt;td class=&quot;border border-muted px-2 py-1&quot; colspan=&quot;1&quot; rowspan=&quot;1&quot;&gt;&lt;p&gt;Excellent&lt;/p&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr class=&quot;border-b border-muted&quot;&gt;&lt;td class=&quot;border border-muted px-2 py-1&quot; colspan=&quot;1&quot; rowspan=&quot;1&quot;&gt;&lt;p&gt;&lt;strong&gt;Typical buyer&lt;/strong&gt;&lt;/p&gt;&lt;/td&gt;&lt;td class=&quot;border border-muted px-2 py-1&quot; colspan=&quot;1&quot; rowspan=&quot;1&quot;&gt;&lt;p&gt;Enterprise IT / CISO&lt;/p&gt;&lt;/td&gt;&lt;td class=&quot;border border-muted px-2 py-1&quot; colspan=&quot;1&quot; rowspan=&quot;1&quot;&gt;&lt;p&gt;Developers / product teams&lt;/p&gt;&lt;/td&gt;&lt;td class=&quot;border border-muted px-2 py-1&quot; colspan=&quot;1&quot; rowspan=&quot;1&quot;&gt;&lt;p&gt;API / platform teams&lt;/p&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr class=&quot;border-b border-muted&quot;&gt;&lt;td class=&quot;border border-muted px-2 py-1&quot; colspan=&quot;1&quot; rowspan=&quot;1&quot;&gt;&lt;p&gt;&lt;strong&gt;Session &amp;amp; logout&lt;/strong&gt;&lt;/p&gt;&lt;/td&gt;&lt;td class=&quot;border border-muted px-2 py-1&quot; colspan=&quot;1&quot; rowspan=&quot;1&quot;&gt;&lt;p&gt;Formal SLO spec (brittle in practice)&lt;/p&gt;&lt;/td&gt;&lt;td class=&quot;border border-muted px-2 py-1&quot; colspan=&quot;1&quot; rowspan=&quot;1&quot;&gt;&lt;p&gt;Front-channel / back-channel logout&lt;/p&gt;&lt;/td&gt;&lt;td class=&quot;border border-muted px-2 py-1&quot; colspan=&quot;1&quot; rowspan=&quot;1&quot;&gt;&lt;p&gt;Not defined (token revocation only)&lt;/p&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr class=&quot;border-b border-muted&quot;&gt;&lt;td class=&quot;border border-muted px-2 py-1&quot; colspan=&quot;1&quot; rowspan=&quot;1&quot;&gt;&lt;p&gt;&lt;strong&gt;Provisioning pair&lt;/strong&gt;&lt;/p&gt;&lt;/td&gt;&lt;td class=&quot;border border-muted px-2 py-1&quot; colspan=&quot;1&quot; rowspan=&quot;1&quot;&gt;&lt;p&gt;Usually paired with SCIM&lt;/p&gt;&lt;/td&gt;&lt;td class=&quot;border border-muted px-2 py-1&quot; colspan=&quot;1&quot; rowspan=&quot;1&quot;&gt;&lt;p&gt;Usually paired with SCIM&lt;/p&gt;&lt;/td&gt;&lt;td class=&quot;border border-muted px-2 py-1&quot; colspan=&quot;1&quot; rowspan=&quot;1&quot;&gt;&lt;p&gt;N/A&lt;/p&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr class=&quot;border-b border-muted&quot;&gt;&lt;td class=&quot;border border-muted px-2 py-1&quot; colspan=&quot;1&quot; rowspan=&quot;1&quot;&gt;&lt;p&gt;&lt;strong&gt;Recommended flow (2026)&lt;/strong&gt;&lt;/p&gt;&lt;/td&gt;&lt;td class=&quot;border border-muted px-2 py-1&quot; colspan=&quot;1&quot; rowspan=&quot;1&quot;&gt;&lt;p&gt;Web Browser SSO Profile&lt;/p&gt;&lt;/td&gt;&lt;td class=&quot;border border-muted px-2 py-1&quot; colspan=&quot;1&quot; rowspan=&quot;1&quot;&gt;&lt;p&gt;Authorization Code + PKCE&lt;/p&gt;&lt;/td&gt;&lt;td class=&quot;border border-muted px-2 py-1&quot; colspan=&quot;1&quot; rowspan=&quot;1&quot;&gt;&lt;p&gt;Authorization Code + PKCE / Client Credentials&lt;/p&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr class=&quot;border-b border-muted&quot;&gt;&lt;td class=&quot;border border-muted px-2 py-1&quot; colspan=&quot;1&quot; rowspan=&quot;1&quot;&gt;&lt;p&gt;&lt;strong&gt;Ease of debugging&lt;/strong&gt;&lt;/p&gt;&lt;/td&gt;&lt;td class=&quot;border border-muted px-2 py-1&quot; colspan=&quot;1&quot; rowspan=&quot;1&quot;&gt;&lt;p&gt;Painful (XML signature errors)&lt;/p&gt;&lt;/td&gt;&lt;td class=&quot;border border-muted px-2 py-1&quot; colspan=&quot;1&quot; rowspan=&quot;1&quot;&gt;&lt;p&gt;Moderate (JWT is inspectable)&lt;/p&gt;&lt;/td&gt;&lt;td class=&quot;border border-muted px-2 py-1&quot; colspan=&quot;1&quot; rowspan=&quot;1&quot;&gt;&lt;p&gt;Moderate&lt;/p&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;

&lt;p&gt;Keep that table bookmarked. The rest of this playbook is just the &quot;why&quot; behind each row.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;1. Protocol Origins: Why Each One Exists&lt;/h2&gt;
&lt;p&gt;Understanding the &lt;em&gt;design goals&lt;/em&gt; of each protocol is the fastest way to stop confusing them.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;SAML 2.0&lt;/strong&gt; was ratified by OASIS in &lt;strong&gt;March 2005&lt;/strong&gt;, consolidating several earlier federation specs (Shibboleth, Liberty Alliance). It was built for a world where enterprises needed to bridge on-prem Active Directory with external web apps over a browser. Its design is optimized for &lt;strong&gt;heavy, attribute-rich assertions&lt;/strong&gt; between two organizations that have a formal, pre-established trust relationship.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;OAuth 2.0&lt;/strong&gt; was published as &lt;strong&gt;RFC 6749 in October 2012&lt;/strong&gt; by the IETF OAuth Working Group. Its explicit goal was &lt;em&gt;not&lt;/em&gt; authentication. It answered the question: &quot;How can a user let &lt;em&gt;App A&lt;/em&gt; read their data in &lt;em&gt;App B&lt;/em&gt; without handing &lt;em&gt;App A&lt;/em&gt; their &lt;em&gt;App B&lt;/em&gt; password?&quot; Think of the first &quot;Sign in with Twitter to post to Facebook&quot; flows &amp;mdash; that is the problem OAuth was invented for.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;OpenID Connect (OIDC)&lt;/strong&gt; launched on &lt;strong&gt;February 26, 2014&lt;/strong&gt;, published by the OpenID Foundation. Developers were misusing OAuth 2.0 as a login protocol (because it was easier than SAML), and doing it badly and insecurely. OIDC standardized the pattern: it adds a signed &lt;strong&gt;ID Token&lt;/strong&gt; (a JWT) on top of OAuth 2.0 so an app can reliably know &lt;em&gt;who&lt;/em&gt; the user is.&lt;/p&gt;
&lt;p&gt;So the lineage is simple: &lt;strong&gt;OAuth gave us delegation, OIDC added identity, SAML was already doing both for enterprises a decade earlier &amp;mdash; just in XML.&lt;/strong&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;2. Token Formats: XML Verbosity vs JWT Portability&lt;/h2&gt;
&lt;p&gt;This is where the protocols feel most different in day-to-day engineering.&lt;/p&gt;
&lt;p&gt;A SAML assertion is a &lt;strong&gt;signed XML document&lt;/strong&gt; &amp;mdash; typically 2&amp;ndash;8 KB, sometimes much larger once attribute statements and certificates are included. Signing uses &lt;strong&gt;XML-DSig&lt;/strong&gt;, and if you have ever debugged an &quot;XML Signature Wrapping&quot; attack or a canonicalization mismatch, you know why SAML has a reputation for being brittle.&lt;/p&gt;
&lt;p&gt;An OIDC ID Token is a &lt;strong&gt;compact JWT&lt;/strong&gt; &amp;mdash; usually a few hundred bytes to 1 KB, Base64URL-encoded, signed via &lt;strong&gt;JWS&lt;/strong&gt; (JSON Web Signatures) using the JOSE framework. You can paste it into &lt;a rel=&quot;nofollow&quot; href=&quot;https://jwt.io/&quot;&gt;jwt.io&lt;/a&gt; and read it. Your backend can verify it in one line of code in almost any language.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Why this matters in production:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;p&gt;JWTs are mobile- and SPA-friendly. SAML assertions are not.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;JWTs can be passed in an &lt;code&gt;Authorization: Bearer&lt;/code&gt; header. SAML assertions typically cannot.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;XML parsing is CPU-intensive. At scale, the difference between parsing 50,000 SAML assertions/sec and 50,000 JWTs/sec is measurable in servers and dollars.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;JWTs have well-known attack classes (&lt;code&gt;alg: none&lt;/code&gt;, key confusion) but they are &lt;em&gt;documented&lt;/em&gt; attack classes. SAML's attack surface includes XSLT injection, entity expansion, and signature wrapping &amp;mdash; a deeper, scarier well.&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;When enterprise customers ask &quot;why are you pushing OIDC?&quot;, the honest answer is: &lt;strong&gt;because JWTs are easier to get right.&lt;/strong&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;3. Transport and Flows&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;SAML rides on the browser.&lt;/strong&gt; The three SAML bindings you will actually see are HTTP-Redirect (for requests, because they are small), HTTP-POST (for responses, because assertions are large), and occasionally HTTP-Artifact (for back-channel retrieval when you cannot trust the browser). Every SAML SSO flow goes: user &amp;rarr; SP &amp;rarr; browser redirect &amp;rarr; IdP &amp;rarr; browser POST &amp;rarr; SP's Assertion Consumer Service (ACS) endpoint.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;OIDC and OAuth ride on REST.&lt;/strong&gt; The recommended flow in 2026 for every client type &amp;mdash; web, SPA, mobile, desktop &amp;mdash; is &lt;strong&gt;Authorization Code with PKCE&lt;/strong&gt; (Proof Key for Code Exchange, RFC 7636). For machine-to-machine calls, OAuth 2.0 provides the &lt;strong&gt;Client Credentials&lt;/strong&gt; grant, which SAML has no real equivalent to.&lt;/p&gt;
&lt;p&gt;The practical consequence: if your product has a mobile app, a CLI, or any non-browser client, SAML alone is insufficient. You will need OIDC or OAuth to cover those surfaces.&lt;/p&gt;
&lt;p&gt;For a deeper look at how modern OAuth flows are evolving, our &lt;a rel=&quot;nofollow&quot; href=&quot;https://ssojet.com/blog/&quot;&gt;OAuth 2.1 security defaults and migration checklist&lt;/a&gt; explains what has been deprecated and why.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;4. Discovery and Key Rotation: Where SAML Ages Badly&lt;/h2&gt;
&lt;p&gt;This is the section that quietly destroys SAML deployments at 3 AM.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;SAML discovery is static.&lt;/strong&gt; Each party publishes an &lt;code&gt;EntityDescriptor&lt;/code&gt; XML metadata file that lists endpoints, supported bindings, and &amp;mdash; critically &amp;mdash; embeds X.509 certificates inside &lt;code&gt;&amp;lt;KeyDescriptor&amp;gt;&lt;/code&gt; elements. You either exchange this file manually (over email, seriously) or host it at a stable URL your partner polls.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Certificate rotation is where SAML deployments fail.&lt;/strong&gt; When your SAML signing cert expires, &lt;em&gt;every downstream SP breaks at once&lt;/em&gt; unless you have published the new cert inside the metadata during an overlap window and every partner has actually refreshed their copy. If you run B2B SAML at any scale, you will eventually have a war story about a Monday morning where 40 enterprise tenants lost SSO because a cert rolled over and half of them had cached the old metadata.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;OIDC discovery is dynamic.&lt;/strong&gt; An OIDC provider publishes &lt;code&gt;/.well-known/openid-configuration&lt;/code&gt;, and inside that document is a &lt;code&gt;jwks_uri&lt;/code&gt; pointing to a live JSON Web Key Set. Keys rotate by simply publishing a new key at the JWKS endpoint with a new &lt;code&gt;kid&lt;/code&gt; (key ID). Clients automatically pick up the rotation on their next token validation. No emails, no coordination, no outages.&lt;/p&gt;
&lt;p&gt;For the full operational playbook on this topic, see our guide on &lt;a rel=&quot;nofollow&quot; href=&quot;https://ssojet.com/blog/&quot;&gt;enterprise federation and SAML metadata management&lt;/a&gt;.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;5. Enterprise vs Consumer Fit&lt;/h2&gt;
&lt;p&gt;There is a reason large, regulated enterprises still demand SAML in 2026. It is not nostalgia &amp;mdash; it is procurement.&lt;/p&gt;
&lt;p&gt;SAML 2.0 has 20 years of deployment inside ADFS, Ping Identity, Shibboleth, and established federation networks like InCommon and eduGAIN. It has been audited into FedRAMP, HIPAA, and banking-sector compliance documents. Your customer's CISO does not care that JWTs are more modern; they care that their auditor already has a checklist for SAML.&lt;/p&gt;
&lt;p&gt;OIDC, on the other hand, dominates modern CIAM (Customer Identity and Access Management), SPAs, mobile apps, and workforce SSO at companies founded after 2015. Entra ID, Okta, Auth0, Google Workspace, and every modern IdP support it natively, and it plays well with everything downstream.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The B2B SaaS reality:&lt;/strong&gt; ship both. Your mid-market customers will ask for OIDC (or be happy with either). Your enterprise customers will hand you a SAML metadata XML file on day one of procurement and expect you to make it work. This is exactly the pattern our &lt;a rel=&quot;nofollow&quot; href=&quot;https://ssojet.com/blog/oidc-vs-saml&quot;&gt;SSO protocol primer: SAML, OIDC and OAuth explained&lt;/a&gt; walks through in more detail.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;6. Session Management and the Single Logout Nightmare&lt;/h2&gt;
&lt;p&gt;SAML has a formal Single Logout (SLO) specification. In practice, it is one of the most reliably broken features in enterprise identity.&lt;/p&gt;
&lt;p&gt;SLO requires the IdP to notify every SP that the user's session ended. The front-channel version does this by redirecting the user's browser through every SP's logout URL sequentially &amp;mdash; which fails the instant the user closes the tab, hits an ad blocker, or a popup blocker intervenes. The back-channel version uses server-to-server SOAP calls, which are more reliable but require every SP to be reachable from the IdP over a network that firewalls rarely permit. The SAML spec itself acknowledges &quot;partial logout&quot; as a valid outcome.&lt;/p&gt;
&lt;p&gt;OIDC defines &lt;strong&gt;front-channel&lt;/strong&gt;, &lt;strong&gt;back-channel&lt;/strong&gt;, and &lt;strong&gt;session management&lt;/strong&gt; logout modes. None of them are perfect either, but they use modern transports (iframes, direct HTTP calls) and standardized JWTs for logout tokens. Failures are easier to debug.&lt;/p&gt;
&lt;p&gt;Honest take: &lt;strong&gt;if you promise customers &quot;one-click log out of everything,&quot; read the fine print of whichever spec you pick.&lt;/strong&gt; SLO is hard in every protocol. Plan for session TTLs and step-up auth rather than relying on SLO to save you.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;7. Provisioning: SCIM Is the Missing Piece&lt;/h2&gt;
&lt;p&gt;Neither SAML nor OIDC handles user lifecycle well on its own. Both can do &lt;strong&gt;Just-In-Time (JIT) provisioning&lt;/strong&gt; &amp;mdash; creating a user account in your app the first time they authenticate &amp;mdash; but JIT has a fatal flaw: &lt;strong&gt;it does not handle deprovisioning.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;When an enterprise fires an employee, the identity provider revokes access instantly. But if your app was relying on JIT, that user's account in &lt;em&gt;your&lt;/em&gt; system still exists, with the last set of attributes it saw. Orphaned accounts are a compliance finding waiting to happen.&lt;/p&gt;
&lt;p&gt;The fix is &lt;strong&gt;SCIM&lt;/strong&gt; (System for Cross-domain Identity Management) &amp;mdash; an out-of-band REST API the IdP calls to create, update, and deactivate users in your app in near real time. Modern B2B SSO is really a three-legged stool: &lt;strong&gt;SAML or OIDC for login + SCIM for lifecycle + MFA enforcement for compliance.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Most enterprise procurement checklists in 2026 require all three. If you implement only SSO, you will still fail security reviews.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;8. Tooling and Developer Ecosystem&lt;/h2&gt;
&lt;p&gt;Okta, Auth0, Microsoft Entra ID, Google, and Ping all support SAML and OIDC. The libraries exist in every major language. But the &lt;em&gt;quality&lt;/em&gt; of experience differs sharply.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;OIDC libraries&lt;/strong&gt; are generally first-class, actively maintained, and have sensible defaults. &lt;code&gt;openid-client&lt;/code&gt; for Node, &lt;code&gt;authlib&lt;/code&gt; for Python, Spring Security OAuth for Java.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;SAML libraries&lt;/strong&gt; tend to be older, less m&lt;/p&gt;&lt;/li&gt;&lt;/ul&gt;
</description>
<category>Other</category>
<guid isPermaLink="true">https://coderlegion.com/15542/saml-vs-oidc-vs-oauth-the-60-second-b2b-playbook</guid>
<pubDate>Thu, 23 Apr 2026 05:29:52 +0000</pubDate>
</item>
<item>
<title>Autopilot - the API Nightmare: How I Defeated LinkedIn Bureaucracy to Automate My Company</title>
<link>https://coderlegion.com/11564/autopilot-the-api-nightmare-how-i-defeated-linkedin-bureaucracy-to-automate-my-company</link>
<description>&lt;p&gt;Until now, everything was fun. We had AI agents with cynical personalities (&lt;a rel=&quot;nofollow&quot; href=&quot;https://datalaria.com/en/posts/ai_agents_part3/&quot;&gt;Post 3&lt;/a&gt;) and a brain capable of analyzing text (&lt;a rel=&quot;nofollow&quot; href=&quot;https://datalaria.com/en/posts/ai_agents_part2/&quot;&gt;Post 2&lt;/a&gt;). But everything lived in the safety of my terminal, on &lt;code&gt;localhost&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;For &lt;strong&gt;Datalaria Autopilot&lt;/strong&gt; to become real, it had to get out into the world.&lt;/p&gt;
&lt;p&gt;Here is where the project stopped being an engineering problem and became a battle against &lt;strong&gt;API Bureaucracy&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://datalaria.com/en/posts/ai_agents_part4/autopilot_api_nightmare.png&quot; alt=&quot;Project Concept Image - The API Nightmare&quot;&gt;&lt;/p&gt;
&lt;h3 id=&quot;the-goal-publishing-as-a-brand-not-a-person&quot;&gt;The Goal: Publishing as a Brand, Not a Person&lt;/h3&gt;
&lt;p&gt;My requirement was clear: I don't want the bot posting on my personal LinkedIn profile. I want it to post on the &lt;strong&gt;Datalaria Company Page&lt;/strong&gt;, with the official logo and a corporate tone.&lt;/p&gt;
&lt;p&gt;Technically, this requires a change in the API endpoint:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Personal Profile: &lt;code&gt;urn:li:person:12345&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;Company Page: &lt;code&gt;urn:li:organization:110125695&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;It looks like a one-line code change. &lt;strong&gt;It ended up being a couple of days of waiting and red tape.&lt;/strong&gt;&lt;/p&gt;
&lt;h3 id=&quot;battle-1-twitter-x-and-the-anti-bot-wall&quot;&gt;Battle 1: Twitter (X) and the Anti-Bot Wall&lt;/h3&gt;
&lt;p&gt;First up, Twitter. Getting API access these days requires passing an audition. I had to apply for the &lt;em&gt;Free Tier&lt;/em&gt; and write a &quot;motivational letter&quot; explaining that I am not a Russian spam bot, but an AI technical enthusiast.&lt;/p&gt;
&lt;p&gt;After getting past the &lt;code&gt;403 Forbidden&lt;/code&gt; error (I forgot to enable &quot;Read &amp;amp; Write&quot; permissions) and the &lt;code&gt;Duplicate Content&lt;/code&gt; error (I tried sending the same &quot;Hello World&quot; twice), I achieved connection. Twitter/X was ready, and in principle, everything worked simply.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://datalaria.com/en/posts/ai_agents_part4/datalaria_twitter_first_publication.png&quot; alt=&quot;Post automatically published on Twitter&quot;&gt;&lt;/p&gt;
&lt;h3 id=&quot;battle-2-the-final-boss-linkedin-company-pages-&quot;&gt;Battle 2: The Final Boss (LinkedIn Company Pages)&lt;/h3&gt;
&lt;p&gt;The biggest problem occurred with LinkedIn.&lt;/p&gt;
&lt;p&gt;I designed my &lt;code&gt;social_manager.py&lt;/code&gt; script to use a company ID if it existed in the environment variables:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;# Hybrid logic in Python
if company_id:
    print(f&quot; Company ID Detected. Posting as page...&quot;)
    author_urn = f&quot;urn:li:organization:{company_id}&quot;
else:
    print(&quot; Posting as personal profile...&quot;)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;When I ran it, the console spat out a blood-red error:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;&lt;code&gt;❌ Error posting to LinkedIn: Status 403: ACCESS_DENIED&lt;/code&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h4 id=&quot;the-ghost-permission-w-organization-social&quot;&gt;The Ghost Permission: &lt;code&gt;w_organization_social&lt;/code&gt;&lt;/h4&gt;
&lt;p&gt;I discovered that the standard LinkedIn token only gives you the &lt;code&gt;w_member_social&lt;/code&gt; permission (posting as a person). The permission for companies (&lt;code&gt;w_organization_social&lt;/code&gt;) &lt;strong&gt;did not exist&lt;/strong&gt; in my developer dashboard.&lt;/p&gt;
&lt;p&gt;To unlock it, I had to complete an administrative scavenger hunt:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Page Verification:&lt;/strong&gt; I had to generate a special URL in the Developer Portal and approve it with my admin account. Result: &lt;em&gt;Company Verified&lt;/em&gt;. ✅&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Even so, it didn't work:&lt;/strong&gt; The permission still didn't appear.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The Hidden Request:&lt;/strong&gt; I had to request access to the &lt;strong&gt;&quot;Marketing Developer Platform&quot;&lt;/strong&gt; product.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The Form:&lt;/strong&gt; LinkedIn made me fill out a questionnaire detailing that I am a &quot;Direct Customer,&quot; that I am not an advertising agency, and that my usage is strictly internal for organic automation.&lt;/li&gt;
&lt;/ol&gt;
&lt;h4 id=&quot;the-victory&quot;&gt;The Victory&lt;/h4&gt;
&lt;p&gt;After a few hours of waiting, the approval email arrived. I re-generated the token and... there it was!&lt;/p&gt;
&lt;p&gt;With the new &quot;Super Token&quot; loaded into my &lt;code&gt;.env&lt;/code&gt;, I ran the script one last time.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;--- TESTING SOCIAL MEDIA MANAGER ---
DTO - Posting to Twitter... ✅ Success!
DTO - Posting to LinkedIn...
 Company ID Detected: 110125695.
✅ LinkedIn Success! Post ID: urn:li:share:741...
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;And the definitive proof on the social network:&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://datalaria.com/en/posts/ai_agents_part4/datalaria_linkedin_first_publication.png&quot; alt=&quot;Post automatically published on the Datalaria LinkedIn Page&quot;&gt;&lt;/p&gt;
&lt;h3 id=&quot;conclusion-and-next-steps&quot;&gt;Conclusion and Next Steps&lt;/h3&gt;
&lt;p&gt;I have achieved what seemed impossible: a Python script that has legal authorization to act as my company.&lt;/p&gt;
&lt;p&gt;But there is one final problem: &lt;strong&gt;This token expires in 60 days.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;If I do nothing, in two months this whole system will break. Furthermore, I am still running the script manually from my laptop.&lt;/p&gt;
&lt;p&gt;In the &lt;strong&gt;final post&lt;/strong&gt; of this series, we are going to automate it all. We will use &lt;strong&gt;GitHub Actions&lt;/strong&gt; so the system runs itself every time I upload an article, and (if the API lets us) we will implement automatic token renewal.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Coming Up - Post 5: Total Automation (CI/CD).&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt; &lt;strong&gt;Source Code:&lt;/strong&gt; The final &lt;code&gt;social_manager.py&lt;/code&gt; module is available in the &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/Dalaez/datalaria-website/tree/main/autopilot&quot;&gt;GitHub repo&lt;/a&gt;.&lt;/p&gt;
</description>
<category>Tutorials</category>
<guid isPermaLink="true">https://coderlegion.com/11564/autopilot-the-api-nightmare-how-i-defeated-linkedin-bureaucracy-to-automate-my-company</guid>
<pubDate>Fri, 13 Feb 2026 07:04:33 +0000</pubDate>
</item>
</channel>
</rss>