diff --git a/src/Network/WindowsLive/App.hs b/src/Network/WindowsLive/App.hs
new file mode 100644
--- /dev/null
+++ b/src/Network/WindowsLive/App.hs
@@ -0,0 +1,124 @@
+module Network.WindowsLive.App
+    ( App
+    , AppID
+    , appId
+    , new
+
+    , decode
+    , verifier
+    )
+where
+
+import Control.Monad ( replicateM, liftM )
+import Control.Monad.Error ( MonadError )
+import qualified Text.Parsec as Parsec
+
+import qualified Network.WindowsLive.Secret as Secret
+import qualified Codec.Binary.Base64 as Base64
+import qualified Codec.Encryption.AES as AES
+import Codec.Encryption.Modes ( unCbc )
+import Codec.Utils ( Octet, fromOctets, toOctets, listFromOctets )
+import Control.Monad ( when )
+import qualified Data.Digest.SHA256 as SHA256
+import Data.HMAC ( hmac, HashMethod(..) )
+import Data.LargeWord ( Word128 )
+import Data.List.Split ( splitOn )
+import Data.Time.Clock.POSIX ( POSIXTime )
+import Network.URI ( unEscapeString )
+import Data.URLEncoded ( (%=), (%&), URLEncoded )
+import qualified Data.URLEncoded as URLEnc
+
+-- |Create a new 'App', validating the Application ID and Secret key
+new :: MonadError e m => String -> String -> m App
+new appIdStr secretStr = do
+  validateAppId appIdStr
+  App appIdStr `liftM` Secret.new secretStr
+
+validateAppId :: MonadError e m => String -> m ()
+validateAppId = either (fail . show) (const $ return ()) .
+                Parsec.parse (replicateM 16 Parsec.hexDigit) "appid"
+-- |Visit
+-- <https://lx.azure.microsoft.com/Cloud/Provisioning/Default.aspx> to
+-- get your application's Application ID and Secret key
+data App = App { appId :: AppID
+               , secret :: Secret.Secret
+               }
+
+type AppID = String
+
+encryptionKey :: App -> Secret.Key
+encryptionKey = Secret.encryptionKey . secret
+
+signingKey :: App -> Secret.Key
+signingKey = Secret.signingKey . secret
+
+-- |Decrypt a token (failing if it cannot be decrypted)
+decodeOnly :: MonadError e m => App -> String -> m String
+decodeOnly app tokStr = do
+  -- First, the string is URL-unescaped and base64 decoded
+  encryptedBytes <- u64 tokStr
+  when (null encryptedBytes) $ fail "Missing initialization vector"
+  when ((length encryptedBytes `mod` 16) /= 0) $
+       fail "Attempted to decode invalid token"
+
+  -- Second, the IV is extracted from the first 16 bytes of the string
+  let initVector:encryptedBlocks = toBlocks encryptedBytes
+
+      -- Finally, the string is decrypted using the encryption key
+      key = fromOctets (256::Integer) $ encryptionKey app :: Word128
+      decryptedBlocks = unCbc AES.decrypt initVector key encryptedBlocks
+
+  return $ stripEOT $ toString decryptedBlocks
+
+-- |Decode, validate, and parse a String containing x-www-urlencoded
+-- |data encrypted with this application's secret
+decode :: MonadError e m => App -> String -> m URLEncoded
+decode app s = do
+  decoded <- decodeOnly app s
+  validate app decoded
+  URLEnc.importString decoded
+
+-- |decode a Base64 encoded, URL-escaped string into a sequence of bytes
+u64 :: MonadError e m => String -> m [Octet]
+u64 str =
+    case Base64.decode $ unEscapeString str of
+      Nothing -> fail "Data was not valid base64"
+      Just bs -> return bs
+
+-- |Check the signature of this token (failing if it is not valid)
+validate :: MonadError e m => App -> String -> m ()
+validate app tok = do
+  (body, sig) <- case splitOn "&sig=" tok of
+                   [b, s] -> return (b, s)
+                   [_] -> fail $ "No sig found: " ++ show tok
+                   unexpected ->
+                       fail $ "More than one sig found: " ++ show unexpected
+
+  extractedSig <- u64 sig
+  let calculatedSig = sign app body
+  when (extractedSig /= calculatedSig) $
+       fail $ "Signature did not match: extracted=" ++ show extractedSig
+                ++ " /= calculated=" ++ show calculatedSig
+
+sign :: App -> String -> [Octet]
+sign app = hmac (HashMethod SHA256.hash 512) (signingKey app) . toBytes
+
+stripEOT :: String -> String
+stripEOT = reverse . dropWhile (== '\EOT') . reverse
+
+toBytes :: String -> [Octet]
+toBytes = map (toEnum . fromEnum)
+
+toString :: [Word128] -> String
+toString = map (toEnum . fromEnum) . concatMap (toOctets (256::Integer))
+
+toBlocks :: [Octet] -> [Word128]
+toBlocks = reverse . listFromOctets . reverse
+
+-- |Generate an application verifier to prove to the server that we
+-- know the secret and application ID
+verifier :: App -> POSIXTime -> URLEncoded
+verifier app ts =
+    let q = "appid" %= appId app %& "ts" %= show (round ts :: Integer)
+        sig = Base64.encode $ sign app $ URLEnc.export q
+    in q %& "sig" %= sig
diff --git a/src/Network/WindowsLive/ConsentToken.hs b/src/Network/WindowsLive/ConsentToken.hs
--- a/src/Network/WindowsLive/ConsentToken.hs
+++ b/src/Network/WindowsLive/ConsentToken.hs
@@ -22,11 +22,11 @@
 import Control.Monad ( liftM, ap, when )
 import Control.Monad.Error ( MonadError )
 import Data.List ( intersperse )
-import Data.Monoid ( mconcat )
+import Data.Maybe ( fromJust )
 import Data.Time.Clock.POSIX ( POSIXTime )
-import Data.URLEncoded ( (%=), (%=?) )
+import Data.URLEncoded ( (%=), (%=?), (%?), (%&), URLEncode(..) )
 import qualified Data.URLEncoded as URLEnc
-import Network.WindowsLive.Token
+import Network.WindowsLive.App ( App, verifier, decode )
 import Network.URI ( parseURI, URI, parseRelativeReference, unEscapeString )
 import Text.Parsec ( parse, many1, char, eof, satisfy, sepBy1 )
 import Data.Char ( isAlpha, isDigit )
@@ -65,6 +65,13 @@
                                             -- request
                  }
 
+instance URLEncode ConsentQuery where
+    urlEncode cq = "ru" %= qReturn cq
+                   %& "pl" %= qPolicy cq
+                   %& "appctx" %=? qContext cq
+                   %& "mkt" %=? qMarket cq
+                   %& "ps" %= offerStr (qOffers cq) ""
+
 -- |Generate a consent query with the minimum information filled in
 consentQuery :: [OfferType] -> URI -> URI -> ConsentQuery
 consentQuery ofrs ru pu = ConsentQuery ofrs ru pu Nothing Nothing
@@ -79,15 +86,8 @@
 -- @
 getConsentUrl :: App -> POSIXTime -> ConsentQuery -> URI
 getConsentUrl app ts cq =
-    let Just pth = parseRelativeReference "Delegation.aspx"
-        q = mconcat [ "app" %= URLEnc.export (appVerifier app ts)
-                    , "ru" %= (show $ qReturn cq)
-                    , "pl" %= (show $ qPolicy cq)
-                    , "appctx" %=? qContext cq
-                    , "mkt" %=? qMarket cq
-                    , "ps" %= offerStr (qOffers cq) ""
-                    ]
-    in URLEnc.addToURI q pth
+    fromJust (parseRelativeReference "Delegation.aspx")
+    %? "app" %= verifier app ts %& urlEncode cq
 
 offerStr :: [OfferType] -> ShowS
 offerStr = foldr (.) id . intersperse (';':) . map shows
@@ -130,9 +130,7 @@
 processConsentToken app ct = do
   encodedToken <- URLEnc.importString (unEscapeString ct)
                   >>= URLEnc.lookup1 "eact"
-  decoded <- decodeToken app encodedToken
-  validateToken app decoded
-  tok <- URLEnc.importString decoded
+  tok <- decode app encodedToken
   let arg n = URLEnc.lookup1 n tok
       ofrs = parseOffers =<< arg "offer"
       expr = do expStr <- arg "exp"
diff --git a/src/Network/WindowsLive/Login.hs b/src/Network/WindowsLive/Login.hs
--- a/src/Network/WindowsLive/Login.hs
+++ b/src/Network/WindowsLive/Login.hs
@@ -13,16 +13,17 @@
     )
 where
 
-import Data.Char ( isDigit )
 import Control.Monad ( liftM, ap, unless )
 import Control.Monad.Error ( MonadError )
+import Data.Char ( isDigit )
+import Data.Maybe ( fromJust )
 import Data.Time.Clock.POSIX ( POSIXTime )
-import Network.WindowsLive.Token
 import Network.URI ( URI, parseURI, parseRelativeReference )
-import Data.Monoid ( mconcat )
 
+import Network.WindowsLive.App ( App, appId, decode )
+
 import qualified Data.URLEncoded as URLEnc
-import Data.URLEncoded ( (%=), (%=?) )
+import Data.URLEncoded ( (%=), (%=?), (%?), (%&) )
 
 baseUrl :: URI
 Just baseUrl = parseURI "http://login.live.com/"
@@ -33,6 +34,9 @@
 appIdQ :: App -> URLEnc.URLEncoded
 appIdQ = ("appid" %=) . appId
 
+relUri :: String -> URI
+relUri = fromJust . parseRelativeReference
+
 -- |Generate a /relative/ authentication start URL
 getLoginUrl :: App
             -> Maybe String -- ^The application context
@@ -40,22 +44,14 @@
                             -- authentication UI
             -> URI
 getLoginUrl app ctx mkt =
-    let Just u = parseRelativeReference "wlogin.srf"
-        loginQuery = mconcat [ appIdQ app
-                             , "alg" %= "wsignin1.0"
-                             , "appctx" %=? ctx
-                             , "mkt" %=? mkt
-                             ]
-    in URLEnc.addToURI loginQuery u
+    relUri "wlogin.srf" %? appIdQ app %& "alg" %= "wsignin1.0"
+                        %& "appctx" %=? ctx %& "mkt" %=? mkt
 
 -- |Generate a /relative/ sign out URL
 getLogoutUrl :: App -> Maybe String -- ^The locale in which to display
                                     -- the sign out process
              -> URI
-getLogoutUrl app mkt =
-    let Just u = parseRelativeReference "logout.srf"
-        logoutQuery = mconcat [ appIdQ app, "mkt" %=? mkt ]
-    in URLEnc.addToURI logoutQuery u
+getLogoutUrl app mkt = relUri "logout.srf" %? appIdQ app %& "mkt" %=? mkt
 
 data User = User { userID :: String
                  , userTimestamp :: POSIXTime
@@ -65,9 +61,7 @@
 -- an error on failure.
 processToken :: MonadError e m => App -> String -> m User
 processToken app encryptedToken = do
-  tok <- decodeToken app encryptedToken
-  validateToken app tok
-  q <- URLEnc.importString tok
+  q <- decode app encryptedToken
   let qval k = URLEnc.lookup1 k q
 
   qAppID <- qval "appid"
diff --git a/src/Network/WindowsLive/Secret.hs b/src/Network/WindowsLive/Secret.hs
new file mode 100644
--- /dev/null
+++ b/src/Network/WindowsLive/Secret.hs
@@ -0,0 +1,45 @@
+module Network.WindowsLive.Secret
+    ( Secret
+    , Key
+    , new
+    , encryptionKey
+    , signingKey
+    )
+where
+
+import Codec.Utils ( Octet )
+import Codec.Text.Raw ( hexdump )
+import Text.PrettyPrint.HughesPJ ( text, (<+>), char )
+import Control.Monad.Error ( MonadError )
+import qualified Data.Digest.SHA256 as SHA256
+
+type Key = [Octet]
+
+newtype Secret = Secret [Octet]
+
+instance Show Secret where
+    showsPrec _ (Secret bs) =
+        shows $ text "Secret<" <+> hexdump 24 bs <+> char '>'
+
+new :: MonadError e m => String -> m Secret
+new s = if null s
+        then fail "Empty secret"
+        else return $ Secret $ map (toEnum . fromEnum) s
+
+data KeyType = Signature | Encryption deriving Show
+
+keyPrefix :: KeyType -> Key
+keyPrefix kt = map (toEnum . fromEnum) $
+               case kt of
+                 Signature -> "SIGNATURE"
+                 Encryption -> "ENCRYPTION"
+
+-- |Generate a cryptographic key from the secret and the key type
+key :: Secret -> KeyType -> Key
+key (Secret bytes) kt = take 16 $ SHA256.hash $ keyPrefix kt ++ bytes
+
+encryptionKey :: Secret -> Key
+encryptionKey = flip key Encryption
+
+signingKey :: Secret -> Key
+signingKey = flip key Signature
diff --git a/src/Network/WindowsLive/Token.hs b/src/Network/WindowsLive/Token.hs
deleted file mode 100644
--- a/src/Network/WindowsLive/Token.hs
+++ /dev/null
@@ -1,145 +0,0 @@
--- |Code that is common to Web Authentication
--- ("Network.WindowsLive.Login") and Delegated Authentication
--- ("Network.WindowsLive.ConsentToken")
-module Network.WindowsLive.Token
-    ( -- * Application State
-      App(..)
-    , AppID
-    , Secret
-    , newApp
-
-    -- * Decryption and validation (internal)
-    , decodeToken
-    , validateToken
-
-    -- * Generate a signed application verifier
-    , appVerifier
-    )
-where
-import qualified Codec.Binary.Base64 as Base64
-import qualified Codec.Encryption.AES as AES
-import Codec.Encryption.Modes ( unCbc )
-import Codec.Text.Raw ( hexdump )
-import Codec.Utils ( Octet, fromOctets, toOctets, listFromOctets )
-import Control.Monad ( when, replicateM )
-import Control.Monad.Error ( MonadError )
-import qualified Data.Digest.SHA256 as SHA256
-import Data.HMAC ( hmac, HashMethod(..) )
-import Data.LargeWord ( Word128 )
-import Data.List.Split ( splitOn )
-import Data.Monoid ( mconcat, mappend )
-import Data.Time.Clock.POSIX ( POSIXTime )
-import Network.URI ( unEscapeString )
-import Data.URLEncoded ( (%=) )
-import qualified Data.URLEncoded as URLEnc
-import qualified Text.Parsec as Parsec
-import Text.PrettyPrint.HughesPJ ( text, (<+>), char )
-
--- |Visit
--- <https://lx.azure.microsoft.com/Cloud/Provisioning/Default.aspx> to
--- get your application's Application ID and Secret key
-data App = App { appId :: AppID
-               , secret :: Secret
-               }
-
-type AppID = String
-
-newtype Secret = Secret [Octet]
-
-instance Show Secret where
-    showsPrec _ (Secret bs) =
-        shows $ text "Secret<" <+> hexdump 24 bs <+> char '>'
-
--- |Create a new 'App', validating the Application ID and Secret key
-newApp :: MonadError e m => String -> String -> m App
-newApp appIdStr secretStr = do
-  validateAppId appIdStr
-  validateSecret secretStr
-  let sec = Secret $ map (toEnum . fromEnum) secretStr
-  return $ App appIdStr $ sec
-
-validateAppId :: MonadError e m => String -> m ()
-validateAppId = either (fail . show) (const $ return ()) .
-                Parsec.parse (replicateM 16 Parsec.hexDigit) "appid"
-
-validateSecret :: MonadError e m => String -> m ()
-validateSecret s = when (null s) $ fail "Empty secret"
-
-data KeyType = Signature | Encryption deriving Show
-
-keyPrefix :: KeyType -> [Octet]
-keyPrefix kt = map (toEnum . fromEnum) $
-               case kt of
-                 Signature -> "SIGNATURE"
-                 Encryption -> "ENCRYPTION"
-
--- |Generate a cryptographic key from the secret and the key type
-derive :: Secret -> KeyType -> [Octet]
-derive (Secret bytes) kt = take 16 $ SHA256.hash $ keyPrefix kt ++ bytes
-
--- |Decrypt a token (failing if it cannot be decrypted)
-decodeToken :: MonadError e m => App -> String -> m String
-decodeToken app tokStr = do
-  -- First, the string is URL-unescaped and base64 decoded
-  encryptedBytes <- u64 tokStr
-  when (null encryptedBytes) $ fail "Missing initialization vector"
-  when ((length encryptedBytes `mod` 16) /= 0) $
-       fail "Attempted to decode invalid token"
-
-  -- Second, the IV is extracted from the first 16 bytes of the string
-  let initVector:encryptedBlocks = toBlocks encryptedBytes
-
-      -- Finally, the string is decrypted using the encryption key
-      key = fromOctets (256::Integer) $ derive (secret app) Encryption :: Word128
-      decryptedBlocks = unCbc AES.decrypt initVector key encryptedBlocks
-
-  return $ stripEOT $ toString decryptedBlocks
-
--- |decode a Base64 encoded, URL-escaped string into a sequence of bytes
-u64 :: MonadError e m => String -> m [Octet]
-u64 str =
-    case Base64.decode $ unEscapeString str of
-      Nothing -> fail "Data was not valid base64"
-      Just bs -> return bs
-
--- |Check the signature of this token (failing if it is not valid)
-validateToken :: MonadError e m => App -> String -> m ()
-validateToken app tok = do
-  (body, sig) <- case splitOn "&sig=" tok of
-                   [b, s] -> return (b, s)
-                   [_] -> fail $ "No sig found: " ++ show tok
-                   unexpected ->
-                       fail $ "More than one sig found: " ++ show unexpected
-
-  extractedSig <- u64 sig
-  let calculatedSig = signToken (secret app) body
-  when (extractedSig /= calculatedSig) $
-       fail $ "Signature did not match: extracted=" ++ show extractedSig
-                ++ " /= calculated=" ++ show calculatedSig
-
-signToken :: Secret -> String -> [Octet]
-signToken sec =
-    hmac (HashMethod SHA256.hash 512) (derive sec Signature) . toBytes
-
-stripEOT :: String -> String
-stripEOT = reverse . dropWhile (== '\EOT') . reverse
-
-toBytes :: String -> [Octet]
-toBytes = map (toEnum . fromEnum)
-
-toString :: [Word128] -> String
-toString = map (toEnum . fromEnum) . concatMap (toOctets (256::Integer))
-
-toBlocks :: [Octet] -> [Word128]
-toBlocks = reverse . listFromOctets . reverse
-
--- |Generate an application verifier to prove to the server that we
--- know the secret and application ID
-appVerifier :: App -> POSIXTime -> URLEnc.URLEncoded
-appVerifier app ts =
-    let q = mconcat [ "appid" %= appId app
-                    , "ts" %= show (round ts :: Integer)
-                    ]
-        token = URLEnc.export q
-        sig = Base64.encode $ signToken (secret app) token
-    in q `mappend` ("sig" %= sig)
diff --git a/test/Test.hs b/test/Test.hs
--- a/test/Test.hs
+++ b/test/Test.hs
@@ -2,7 +2,7 @@
 where
 
 import Control.Monad.Error ( MonadError )
-import qualified Network.WindowsLive.Token as Live
+import qualified Network.WindowsLive.App as Live
 import qualified Data.URLEncoded as URLEnc
 import Network.WindowsLive.Login ( processToken, getLoginUrl, baseUrl )
 import Network.WindowsLive.ConsentToken ( processConsentToken )
@@ -50,7 +50,7 @@
 secret = "wfMkKPJdTHHi64sWrmQlGE9uBY27Pjgl"
 
 getApp :: MonadError e m => m Live.App
-getApp = Live.newApp appId secret
+getApp = Live.new appId secret
 
 -- Very weak tests: test that processing these good strings does not
 -- throw an exception
@@ -63,8 +63,8 @@
 
 badAppTest :: Test
 badAppTest =
- test [ "validates secret" ~: assert $ fails (Live.newApp appId "")
-      , "validates app id" ~: assert $ fails (Live.newApp "" secret)
+ test [ "validates secret" ~: assert $ fails (Live.new appId "")
+      , "validates app id" ~: assert $ fails (Live.new "" secret)
       ]
     where fails :: Either String Live.App -> Bool
           fails (Left _) = True
diff --git a/windowslive.cabal b/windowslive.cabal
--- a/windowslive.cabal
+++ b/windowslive.cabal
@@ -1,6 +1,6 @@
 Name:                windowslive
 Cabal-Version:       >= 1.6
-Version:             0.1.1.1
+Version:             0.2
 Synopsis:            Implements Windows Live Web Authentication and
                      Delegated Authentication
 
@@ -23,7 +23,7 @@
   Build-Depends:
     base == 4.*,
     Crypto == 4.2.*,
-    dataenc == 0.12,
+    dataenc == 0.13.*,
     mtl == 1.*,
     network == 2.2.*,
     pretty == 1.*,
@@ -31,12 +31,14 @@
     time == 1.1.*,
     parsec >= 3.0.0 && < 3.1,
     HUnit == 1.2.*,
-    urlencoded
+    urlencoded == 0.1.*
   HS-Source-Dirs: src
   Exposed-Modules:
-    Network.WindowsLive.Token,
+    Network.WindowsLive.App,
     Network.WindowsLive.Login,
     Network.WindowsLive.ConsentToken
+  Other-Modules:
+    Network.WindowsLive.Secret
 
 -- This executable is not installed by the (custom) Setup program. It is
 -- used by the test hook (cabal test)
