diff --git a/README.md b/README.md
new file mode 100644
--- /dev/null
+++ b/README.md
diff --git a/hyperbole-oauth2.cabal b/hyperbole-oauth2.cabal
new file mode 100644
--- /dev/null
+++ b/hyperbole-oauth2.cabal
@@ -0,0 +1,59 @@
+cabal-version: 2.2
+
+-- This file has been generated from package.yaml by hpack version 0.39.5.
+--
+-- see: https://github.com/sol/hpack
+
+name:           hyperbole-oauth2
+version:        0.7.0
+synopsis:       OAuth2 Support for Hyperbole
+description:    OAuth2 Support for the Hyperbole framework
+category:       Web, Network
+homepage:       https://github.com/seanhess/hyperbole
+bug-reports:    https://github.com/seanhess/hyperbole/issues
+author:         Sean Hess
+maintainer:     seanhess@gmail.com
+license:        BSD-3-Clause
+build-type:     Simple
+extra-doc-files:
+    README.md
+
+source-repository head
+  type: git
+  location: https://github.com/seanhess/hyperbole
+
+library
+  exposed-modules:
+      Web.Hyperbole.OAuth2
+  other-modules:
+      Paths_hyperbole_oauth2
+  autogen-modules:
+      Paths_hyperbole_oauth2
+  hs-source-dirs:
+      src
+  default-extensions:
+      OverloadedStrings
+      OverloadedRecordDot
+      DuplicateRecordFields
+      NoFieldSelectors
+      TypeFamilies
+      DataKinds
+      DerivingStrategies
+      DeriveAnyClass
+  ghc-options: -Wall -fdefer-typed-holes
+  build-depends:
+      aeson >=2.1.2.1 && <2.4
+    , base >=4.16 && <5
+    , bytestring >=0.11 && <0.13
+    , casing >=0.1.2 && <0.2
+    , containers >=0.6 && <1
+    , data-default ==0.8.*
+    , effectful >=2.4 && <3
+    , http-client ==0.7.*
+    , http-client-tls >=0.3 && <0.5
+    , http-types ==0.12.*
+    , hyperbole >=0.6 && <1
+    , random >=1.2 && <2
+    , string-conversions ==0.4.*
+    , text >=1.2 && <3
+  default-language: GHC2021
diff --git a/src/Web/Hyperbole/OAuth2.hs b/src/Web/Hyperbole/OAuth2.hs
new file mode 100644
--- /dev/null
+++ b/src/Web/Hyperbole/OAuth2.hs
@@ -0,0 +1,293 @@
+{-# LANGUAGE CPP #-}
+{-# LANGUAGE DerivingVia #-}
+{-# LANGUAGE LambdaCase #-}
+
+{- |
+Module:      Web.Hyperbole.OAuth2
+Copyright:   (c) 2024 Sean Hess
+License:     BSD3
+Maintainer:  Sean Hess <seanhess@gmail.com>
+Stability:   experimental
+Portability: portable
+
+OAuth2 support for [Hyperbole](https://hackage.haskell.org/package/hyperbole)
+
+* [hyperbole.live](https://hyperbole.live) - documentation and examples
+* [github](https://github.com/seanhess/hyperbole) - issues and source code
+-}
+module Web.Hyperbole.OAuth2
+  ( OAuth2 (..)
+  , authUrl
+  , validateCode
+  , exchangeAuth
+  , exchangeRefresh
+  , runOAuth2
+  , getConfigEnv
+  , Scopes (..)
+  , AuthFlow (..)
+  , Config (..)
+  , TokenType (..)
+  , Authenticated (..)
+  , Token (..)
+  , ClientId
+  , ClientSecret
+  , Code
+  , Access
+  , Refresh
+  , State
+  , Auth
+  , OAuth2Error (..)
+  ) where
+
+import Control.Monad (unless, when)
+import Data.Aeson (FromJSON (..), Options (..), ToJSON (..), Value (..), defaultOptions, eitherDecode, genericParseJSON, genericToJSON)
+import Data.ByteString.Lazy qualified as BL
+import Data.Default
+import Data.Maybe (isJust)
+import Data.String (IsString (..))
+import Data.String.Conversions (cs)
+import Data.Text (Text)
+import Data.Text qualified as T
+import Effectful
+import Effectful.Dispatch.Dynamic
+import Effectful.Environment
+import Effectful.Exception
+import GHC.Generics (Generic)
+import Network.HTTP.Client (HttpException, Request (..), RequestBody (..))
+import Network.HTTP.Client qualified as HTTP
+import Network.HTTP.Types (hAccept, hContentType)
+import Text.Casing (quietSnake)
+import Web.Hyperbole.Data.URI
+import Web.Hyperbole.Effect.GenRandom
+import Web.Hyperbole.Effect.Hyperbole
+import Web.Hyperbole.Effect.Query
+import Web.Hyperbole.Effect.Response (respondError)
+import Web.Hyperbole.Effect.Session (Session (..), deleteSession, saveSession, session)
+import Web.Hyperbole.Types.Response
+
+
+authUrl :: (OAuth2 :> es) => URI -> Scopes -> Eff es URI
+authUrl redirectUrl scopes = send $ AuthUrl redirectUrl scopes
+
+
+validateCode :: (OAuth2 :> es) => Eff es (Token Code)
+validateCode = send ValidateCode
+
+
+exchangeAuth :: (OAuth2 :> es) => Token Code -> Eff es Authenticated
+exchangeAuth authCode = send $ ExchangeAuth authCode
+
+
+exchangeRefresh :: (OAuth2 :> es) => Token Refresh -> Eff es Authenticated
+exchangeRefresh refToken = send $ ExchangeRefresh refToken
+
+
+data OAuth2 :: Effect where
+  AuthUrl :: URI -> Scopes -> OAuth2 m URI
+  ValidateCode :: OAuth2 m (Token Code)
+  ExchangeAuth :: Token Code -> OAuth2 m Authenticated
+  ExchangeRefresh :: Token Refresh -> OAuth2 m Authenticated
+
+
+type instance DispatchOf OAuth2 = 'Dynamic
+
+
+runOAuth2
+  :: (GenRandom :> es, IOE :> es, Hyperbole :> es)
+  => Config
+  -> HTTP.Manager
+  -> Eff (OAuth2 : es) a
+  -> Eff es a
+runOAuth2 cfg mgr = interpret $ \_ -> \case
+  AuthUrl red scopes -> do
+    state <- genRandomToken 6
+    let url = authorizationUrl cfg.authorize cfg.clientId red scopes state
+    saveSession $ AuthFlow red state
+    pure url
+  ValidateCode -> do
+    flow <- session @AuthFlow
+    validateRedirectParams flow
+  ExchangeAuth authCode -> do
+    flow <- session @AuthFlow
+    let params = tokenParams cfg.clientId cfg.clientSecret flow.redirect authCode
+    auth <- sendTokenRequest cfg mgr params
+    deleteSession @AuthFlow
+    pure auth
+  ExchangeRefresh refToken -> do
+    let params = refreshParams cfg.clientId cfg.clientSecret refToken
+    sendTokenRequest cfg mgr params
+
+
+{- | read oauth config from env. This is not required, you can obtain these secrets another way
+and configure the app however you please. Just pass the results into runOAuth2 in your app
+-}
+getConfigEnv :: (Environment :> es) => Eff es Config
+getConfigEnv = do
+  clientId <- Token . cs <$> getEnv "OAUTH2_CLIENT_ID"
+  clientSecret <- Token . cs <$> getEnv "OAUTH2_CLIENT_SECRET"
+  authorize <- Endpoint <$> getEnvURI "OAUTH2_AUTHORIZE_ENDPOINT"
+  token <- Endpoint <$> getEnvURI "OAUTH2_TOKEN_ENDPOINT"
+  pure $ Config{clientId, clientSecret, authorize, token}
+ where
+  getEnvURI n = do
+    str <- getEnv n
+    case parseURI str of
+      Nothing -> throwIO $ OAuth2BadEnv n str
+      Just u -> pure u
+
+
+-- Types -------------------------------------------------
+
+newtype Scopes = Scopes [Text]
+  deriving (Show, Generic)
+instance ToJSON Scopes where
+  toJSON (Scopes ss) = String $ T.unwords ss
+instance FromJSON Scopes where
+  parseJSON v = do
+    t <- parseJSON @String v
+    pure $ fromString t
+instance IsString Scopes where
+  fromString s = Scopes $ T.words $ cs s
+
+
+data ClientId
+data ClientSecret
+data Code
+data Refresh
+data Access
+data State
+data Auth
+
+#if (!MIN_VERSION_aeson(2,2,0))
+instance FromJSON URI
+instance ToJSON URI
+instance FromJSON URIAuth
+instance ToJSON URIAuth
+#endif
+
+
+data AuthFlow = AuthFlow
+  { redirect :: URI
+  , state :: Token State
+  }
+  deriving (Generic, FromJSON, ToJSON)
+instance Session AuthFlow where
+  sessionKey = "OAuth2AuthFlow"
+  cookiePath = Just "/"
+instance Default AuthFlow where
+  def = AuthFlow (pathUri "/") (Token mempty)
+
+
+data Config = Config
+  { clientId :: Token ClientId
+  , clientSecret :: Token ClientSecret
+  , authorize :: Endpoint Auth
+  , token :: Endpoint (Token ())
+  }
+
+
+data TokenType
+  = Bearer
+  deriving (Show, Read, Generic)
+instance ToJSON TokenType where
+  toJSON s = toJSON $ show s
+instance FromJSON TokenType where
+  parseJSON (String ttyp) | T.toLower ttyp == "bearer" = pure Bearer
+  parseJSON val = fail $ "expected TokenType but got " <> show val
+
+
+data Authenticated = Authenticated
+  { tokenType :: TokenType
+  , expiresIn :: Maybe Int
+  , scope :: Maybe Scopes
+  , accessToken :: Token Access
+  , refreshToken :: Maybe (Token Refresh)
+  }
+  deriving (Generic, Show)
+instance FromJSON Authenticated where
+  parseJSON = genericParseJSON defaultOptions{fieldLabelModifier = quietSnake}
+instance ToJSON Authenticated where
+  toJSON = genericToJSON defaultOptions{fieldLabelModifier = quietSnake}
+instance Session Authenticated where
+  sessionKey = "OAuth2Authenticated"
+  cookiePath = Just "/"
+
+
+data OAuth2Error
+  = OAuth2BadResponse String BL.ByteString
+  | OAuth2TokenRequest HttpException
+  | OAuth2BadEnv String String
+  deriving (Show, Exception)
+
+
+-- Lower level --------------------------------------------------
+
+authorizationUrl :: Endpoint Auth -> Token ClientId -> URI -> Scopes -> Token State -> URI
+authorizationUrl (Endpoint auth) (Token cid) redUrl (Scopes scopes) (Token state) =
+  auth{uriQuery = cs $ renderQuery True authParams}
+ where
+  authParams =
+    [ ("response_type", Just "code")
+    , ("client_id", Just $ cs cid)
+    , ("redirect_uri", Just $ cs $ uriToText redUrl)
+    , ("scope", Just $ cs $ T.intercalate " " scopes)
+    , ("state", Just $ cs state)
+    ]
+
+
+tokenParams :: Token ClientId -> Token ClientSecret -> URI -> Token Code -> Query
+tokenParams (Token cid) (Token sec) redUrl (Token ac) =
+  [ ("grant_type", Just "authorization_code")
+  , ("client_id", Just $ cs cid)
+  , ("client_secret", Just $ cs sec)
+  , ("redirect_uri", Just $ cs $ uriToText redUrl)
+  , ("code", Just $ cs ac)
+  ]
+
+
+refreshParams :: Token ClientId -> Token ClientSecret -> Token Refresh -> Query
+refreshParams (Token cid) (Token sec) (Token ref) =
+  [ ("grant_type", Just "refresh_token")
+  , ("client_id", Just $ cs cid)
+  , ("client_secret", Just $ cs sec)
+  , ("refresh_token", Just $ cs ref)
+  ]
+
+
+validateRedirectParams :: (Hyperbole :> es) => AuthFlow -> Eff es (Token Code)
+validateRedirectParams flow = do
+  err <- lookupParam @Text "error"
+
+  when (isJust err) $ do
+    desc <- param "error_description"
+    respondError $ ErrAuth desc
+
+  authState <- param @(Token State) "state"
+  authCode <- param @(Token Code) "code"
+
+  unless (flow.state == authState) $ do
+    respondError $ ErrAuth "Oauth2 State mismatch"
+
+  pure authCode
+
+
+sendTokenRequest :: (IOE :> es) => Config -> HTTP.Manager -> Query -> Eff es Authenticated
+sendTokenRequest cfg mgr params = do
+  baseReq <- HTTP.requestFromURI cfg.token.uri
+
+  let req =
+        baseReq
+          { method = "POST"
+          , requestBody = RequestBodyBS $ renderQuery False params
+          , requestHeaders =
+              [ (hContentType, "application/x-www-form-urlencoded")
+              , (hAccept, "application/json")
+              ]
+          }
+
+  res <- liftIO (HTTP.httpLbs req mgr) `catch` (throwIO . OAuth2TokenRequest)
+
+  let body = HTTP.responseBody res
+  case eitherDecode @Authenticated body of
+    Left e -> throwIO $ OAuth2BadResponse e body
+    Right tr -> pure tr
