OAuth 2.0 is the industry-standard protocol for delegated authorization. It allows users to grant third-party applications limited access to their resources without sharing credentials. While most developers consume OAuth as clients (e.g., “Sign in with Google”), building an OAuth 2.0 provider—an authorization server that issues tokens and manages consent—is a substantially more complex undertaking that touches cryptography, session management, token lifecycle, and security hardening against a well-documented catalog of attacks.
The Ory Fosite library provides a security-first, extensible framework for building OAuth 2.0 and OpenID Connect servers in Go. Rather than implementing the RFC specifications from scratch (a path littered with subtle vulnerabilities), Fosite provides composable handlers for each grant type, token format, and OIDC feature. You wire these handlers together with your storage backend and business logic to produce a standards-compliant authorization server tailored to your system’s requirements.
In a SaaS platform like the platform, an OAuth 2.0 provider enables external systems—CI/CD pipelines, IDE plugins, CLI tools, and partner integrations—to authenticate against the platform’s API. The implementation uses Fosite with OIDC extensions, supports the authorization code grant with PKCE, client credentials for machine-to-machine authentication, and refresh token rotation. Tokens are JWTs signed with RS256, with key rotation managed through a JWKS endpoint.
The protocol defines four roles: the Resource Owner (the user), the Client (the third-party application), the Authorization Server (issues tokens—what we’re building), and the Resource Server (the API that validates tokens). In many deployments, the authorization server and resource server are the same application, but separating them architecturally is important for understanding the protocol’s security boundaries.
Each grant type addresses a different authentication scenario. The Authorization Code grant is for web/mobile apps where the user is present. Client Credentials is for machine-to-machine communication. Refresh Token extends session lifetime without re-authentication. The deprecated Implicit and Resource Owner Password grants should not be implemented in new systems.
PKCE (RFC 7636) protects the authorization code grant against interception attacks, especially for public clients (mobile apps, SPAs) that cannot securely store a client secret. The client generates a random code_verifier, derives a code_challenge via SHA-256, sends the challenge with the authorization request, and proves possession of the verifier when exchanging the code for tokens. PKCE is now recommended for all clients, not just public ones (per OAuth 2.1 draft).
OIDC is an identity layer built on top of OAuth 2.0. It adds an id_token (a JWT containing user identity claims), a userinfo endpoint, and a discovery mechanism (.well-known/openid-configuration). While OAuth 2.0 handles authorization (“can this app access this resource?”), OIDC handles authentication (“who is this user?”). Most modern OAuth providers implement both.
An OAuth provider issues multiple token types: access tokens (short-lived, authorize API calls), refresh tokens (long-lived, used to obtain new access tokens), and ID tokens (OIDC, contain user identity claims). Access tokens can be opaque strings (requiring introspection) or self-contained JWTs (validated locally by resource servers).
The most common flow proceeds through these steps: (1) The client redirects the user to the authorization endpoint with a code_challenge. (2) The user authenticates and consents. (3) The authorization server redirects back with an authorization code. (4) The client exchanges the code plus code_verifier at the token endpoint. (5) The server validates the verifier against the stored challenge, issues tokens.
// Authorization request (step 1)
GET /oauth/authorize?
response_type=code&
client_id=app-cli&
redirect_uri=http://localhost:8085/callback&
scope=openid profile api:read&
state=random-csrf-token&
code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM&
code_challenge_method=S256
// Token exchange (step 4)
POST /oauth/token
Content-Type: application/x-www-form-urlencoded
grant_type=authorization_code&
code=SplxlOBeZQQYbYS6WxSbIA&
redirect_uri=http://localhost:8085/callback&
client_id=app-cli&
code_verifier=dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk
Fosite works through composable handlers. Each handler implements one aspect of the OAuth specification. You register handlers for the grant types and features you need, and Fosite chains them during request processing. This is more explicit than monolithic OAuth libraries but gives you precise control over which features are enabled.
func NewOAuth2Provider(store storage.Store, secret []byte, privateKey *rsa.PrivateKey) fosite.OAuth2Provider {
config := &fosite.Config{
AccessTokenLifespan: time.Hour,
RefreshTokenLifespan: time.Hour * 24 * 30,
AuthorizeCodeLifespan: time.Minute * 10,
GlobalSecret: secret,
SendDebugMessagesToClients: false,
}
// JWT strategy for access tokens
jwtStrategy := &oauth2.DefaultJWTStrategy{
Signer: &jwt.RS256JWTStrategy{PrivateKey: privateKey},
Config: config,
}
// Compose handlers for supported grant types
provider := compose.Compose(
config,
store,
jwtStrategy,
compose.OAuth2AuthorizeExplicitFactory, // authorization code grant
compose.OAuth2ClientCredentialsGrantFactory, // client credentials
compose.OAuth2RefreshTokenGrantFactory, // refresh tokens
compose.OAuth2PKCEFactory, // PKCE
compose.OpenIDConnectExplicitFactory, // OIDC authorization code
compose.OpenIDConnectRefreshFactory, // OIDC refresh
)
return provider
}
JWT access tokens are signed with RSA (RS256) or ECDSA (ES256) private keys. The corresponding public keys are published at a /.well-known/jwks.json endpoint so resource servers can validate tokens without contacting the authorization server. Key rotation involves adding a new key to the JWKS, signing new tokens with it, and eventually removing the old key after all outstanding tokens expire.
The platform exposes standard OAuth endpoints backed by Fosite: /oauth/authorize for user-facing authorization, /oauth/token for token exchange, /oauth/revoke for token revocation, /oauth/introspect for token introspection, and /.well-known/openid-configuration for OIDC discovery. The authorize endpoint renders a consent screen showing which scopes the client is requesting, and the user can approve or deny.
func RegisterOAuthRoutes(r chi.Router, provider fosite.OAuth2Provider, sessions *SessionStore) {
r.Get("/oauth/authorize", handleAuthorize(provider, sessions))
r.Post("/oauth/authorize", handleAuthorizeConsent(provider, sessions))
r.Post("/oauth/token", handleToken(provider))
r.Post("/oauth/revoke", handleRevoke(provider))
r.Post("/oauth/introspect", handleIntrospect(provider))
r.Get("/.well-known/openid-configuration", handleOIDCDiscovery)
r.Get("/.well-known/jwks.json", handleJWKS)
}
func handleToken(provider fosite.OAuth2Provider) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
session := NewOIDCSession("")
accessReq, err := provider.NewAccessRequest(ctx, r, session)
if err != nil {
provider.WriteAccessError(ctx, w, accessReq, err)
return
}
// Grant requested scopes based on client configuration
for _, scope := range accessReq.GetRequestedScopes() {
accessReq.GrantScope(scope)
}
response, err := provider.NewAccessResponse(ctx, accessReq)
if err != nil {
provider.WriteAccessError(ctx, w, accessReq, err)
return
}
provider.WriteAccessResponse(ctx, w, accessReq, response)
}
}
CI/CD systems and internal services authenticate using the client credentials grant. Each service is registered as a confidential client with a client ID and secret. The service exchanges these credentials directly for an access token without user involvement. The platform scopes these tokens narrowly—a CI pipeline might only have api:deploy scope, preventing lateral access if the token is compromised.
type Client struct {
ID string `json:"id" db:"id"`
Secret string `json:"-" db:"secret_hash"`
RedirectURIs []string `json:"redirect_uris" db:"redirect_uris"`
GrantTypes []string `json:"grant_types" db:"grant_types"`
ResponseTypes []string `json:"response_types" db:"response_types"`
Scopes []string `json:"scopes" db:"scopes"`
Public bool `json:"public" db:"is_public"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
}
// Implements fosite.Client interface
func (c *Client) GetID() string { return c.ID }
func (c *Client) GetHashedSecret() []byte { return []byte(c.Secret) }
func (c *Client) GetRedirectURIs() []string { return c.RedirectURIs }
func (c *Client) GetGrantTypes() fosite.Arguments { return c.GrantTypes }
func (c *Client) GetResponseTypes() fosite.Arguments { return c.ResponseTypes }
func (c *Client) GetScopes() fosite.Arguments { return c.Scopes }
func (c *Client) IsPublic() bool { return c.Public }
// Fosite handles rotation automatically when configured:
// - Old refresh token is invalidated on use
// - New refresh token is issued with the access token
// - Grace period allows retry if the client didn't receive the response
//
// Storage must implement these atomically:
func (s *SQLStore) RevokeRefreshToken(ctx context.Context, requestID string) error {
_, err := s.db.ExecContext(ctx,
"UPDATE oauth_refresh_tokens SET active = false WHERE request_id = $1",
requestID,
)
return err
}
func (s *SQLStore) RevokeRefreshTokenMaybeGracePeriod(ctx context.Context, requestID string, signature string) error {
// Allow a grace period for network failures:
// if the token was used less than 30 seconds ago, don't revoke it yet
_, err := s.db.ExecContext(ctx, `
UPDATE oauth_refresh_tokens
SET active = false
WHERE request_id = $1
AND signature != $2
AND used_at < NOW() - INTERVAL '30 seconds'`,
requestID, signature,
)
return err
}
Scopes define the boundaries of access. The authorization server validates that requested scopes are a subset of what the client is allowed to request, and the user must consent to each scope. Resource servers check the scope claims in access tokens to enforce fine-grained permissions. Use hierarchical scopes like api:read, api:write, api:admin for clear, composable authorization.
redirect_uri in the authorization request exactly matches a pre-registered URI. Partial matching or wildcard subdomains allow attackers to redirect authorization codes to their own servers.Hasher interface—configure it with bcrypt, not a custom scheme.state parameter and use CSRF tokens on the consent form to prevent authorization code injection attacks.