Passwords are not necessary, a Oauth/OIDC approach

Intro It is possible to skip the burden of password management, security, encryption and password rotations. In this document, I will explain an approach using OAuth 2.0 with a React frontend and a Kotlin Spring Boot sequenceDiagram Frontend->>Frontend: loads script and initialize with CLIENT_ID Frontend->>+Google Identity Services: modal login Google Identity Services-->>Frontend: credentials Frontend->>+Our API Authentication Service: credentials Our API Authentication Service->>+Google Identi
Intro
It is possible to skip the burden of password management, security, encryption and password rotations.
When using OAuth 2.0 with OIDC (OpenID Connect), the user can log in with third-party accounts like
Google, GitHub, X and others — an easier way to sign in.
In this document, I will explain an approach using OAuth 2.0 with a React frontend and a Kotlin Spring Boot
backend to validate the third-party credentials and generate our own.
The flow at a glance
sequenceDiagram
Frontend->>Frontend: loads script and initialize with CLIENT_ID
Frontend->>+Google Identity Services: modal login
Google Identity Services-->>Frontend: credentials
Frontend->>+Our API Authentication Service: credentials
Our API Authentication Service->>+Google Identity Services: validate credentials
Google Identity Services-->>-Our API Authentication Service: confirmation
Our API Authentication Service-->>-Frontend: ours generated tokens
Enter fullscreen mode Exit fullscreen mode
OAuth 2.0
OAuth 2.0 is an authorization framework: it lets a user grant an application access to a resource without sharing a password with that application. On its own OAuth 2.0 says nothing about "who the user is", that is OpenID Connect (OIDC), a thin identity layer
on top of OAuth 2.0 that adds the ID token: a signed JWT asserting an authenticated identity (email, subject, issuer, audience, expiry).
Google Identity Services implements OIDC, so the "credential" the browser receives is an OIDC ID token. We use it purely as
proof of identity, then mint our own tokens for authorization inside our API.
OpenID Connect
OpenID Connect (OIDC) is a thin authentication layer standardized on top of OAuth 2.0. Where OAuth 2.0 only answers "is this app allowed to access something?", OIDC answers "who
is this user, and has the provider authenticated them?" It does this by adding the ID token — a JWT, signed by the identity provider, whose standard claims describe the authenticated user: sub (a stable user id), iss (who issued it), aud (which app it was issued for), exp/iat (validity window), and identity claims like email and email_verified.
OIDC also standardizes discovery (a /.well-known/openid-configuration document) and a JWKS endpoint publishing the provider's public signing keys, so any backend can verify an ID token's signature offline without calling the provider on every
login(we calling it on every login).
Google Identity Services is an OIDC provider, so the credential our frontend receives is an OIDC ID token, and our API trusts it only after validating those standard claims (issuer, audience, signature, expiry, email_verified) against Google's published keys.
Key OAuth 2.0 / OIDC concepts used here
- ID token (the credential) — a JWT signed by Google. Its trust comes from Google's signature, not from the channel it travelled on.
-
Issuer (
iss) — who minted the token. We trust only a configured allow-list (https://accounts.google.com). -
Audience (
aud) — who the token was minted for. It must equal our GoogleCLIENT_ID, otherwise a token issued for some other app could be replayed against us. - JWKS / OIDC discovery — Google publishes its public signing keys at a well-known URL.( it is possible to cache the result to prevent rework)
-
Signature, expiry, and claim checks — standard JWT validation: the token must be unexpired, correctly signed, and carry a
email_verified: trueclaim.
Why we don't just keep using the Google token
The provider's ID token proves the user is who they say they are, but it is the wrong credential to trust on our APIs. The main reasons are:
- It is issued for Google's lifecycle, not ours — we cannot control its TTL, claims, or revocation.
- It carries Google's notion of the user, not ours — it knows nothing about the user's authorizations, roles and other controls related to our system.
- It is short-lived and not designed to be replayed against our backend on every call.
So we follow the standard pattern: verify the external credential once, then issuefirst-party tokens scoped to our domain. From that point on, our API only ever trusts our own tokens.
Implementation
1. Frontend obtains the Google credential
The SPA loads Google Identity Services, initializes it with our CLIENT_ID, and shows the Google sign-in modal. Google returns an OIDC credential (ID token JWT) to the page.
First, create an account on https://console.cloud.google.com, create a project and an OAuth 2.0 client, and get the Client ID. You will also need to configure the authorized JavaScript origins.
To test locally, I added http://localhost:4200, for example.
Create a component to call the scripts and handle the authentication response.
<Suspense fallback={<p>Loading sign-in…</p>}>
<AuthApp onAuthenticated={onAuthenticated} />
</Suspense>
Enter fullscreen mode Exit fullscreen mode
Now we add a component to handle future providers; for now we are only use Google.
//auth/src/AuthApp.tsx
import { useCallback, useEffect, useRef, useState } from 'react';
import { providers } from './auth/providers';
import type { AuthHandlers, AuthProvider, AuthResult } from './auth/types';
export interface AuthAppProps {
/** Called with the provider result (incl. the Google-signed ID token). */
onAuthenticated?: (result: AuthResult) => void;
}
// Mounts one provider's sign-in UI into a container div. Google renders its
// official button here; the effect runs once per provider, reading the latest
// handlers via a ref so changing callbacks doesn't re-mount the button.
function ProviderSlot({
provider,
handlers,
}: {
provider: AuthProvider;
handlers: AuthHandlers;
}) {
const ref = useRef<HTMLDivElement>(null);
const handlersRef = useRef(handlers);
handlersRef.current = handlers;
useEffect(() => {
const el = ref.current;
if (!el) return;
const cleanup = provider.mount(el, {
onResult: (r) => handlersRef.current.onResult(r),
onError: (e) => handlersRef.current.onError(e),
});
return () => {
if (typeof cleanup === 'function') cleanup();
el.replaceChildren();
};
}, [provider]);
return <div ref={ref} />;
}
export function AuthApp({ onAuthenticated }: AuthAppProps) {
const [error, setError] = useState<string | null>(null);
const onResult = useCallback(
(result: AuthResult) => {
setError(null);
onAuthenticated?.(result);
},
[onAuthenticated],
);
const onError = useCallback((e: Error) => setError(e.message), []);
return (
<div style={{ display: 'grid', gap: 12, maxWidth: 320 }}>
<h2 style={{ margin: 0 }}>Sign in</h2>
{providers.map((provider) => (
<ProviderSlot
key={provider.id}
provider={provider}
handlers={{ onResult, onError }}
/>
))}
{error && <p style={{ color: 'crimson', margin: 0 }}>{error}</p>}
</div>
);
}
export default AuthApp;
Enter fullscreen mode Exit fullscreen mode
//auth/src/auth/providers.ts
import { googleProvider } from './google';
import type { AuthProvider } from './types';
// The client-side sign-in providers shown in the auth widget. Google is first.
// Add another provider by implementing its `mount` and appending it here.
export const providers: AuthProvider[] = [googleProvider];
Enter fullscreen mode Exit fullscreen mode
//auth/src/auth/google.ts
const GSI_SRC = 'https://accounts.google.com/gsi/client';
const CLIENT_ID = import.meta.env.VITE_GOOGLE_CLIENT_ID ?? '';
let gsiPromise: Promise<void> | null = null;
// Inject the Google Identity Services script once and resolve when ready.
function loadGsi(): Promise<void> {
gsiPromise ??= new Promise<void>((resolve, reject) => {
if (typeof window !== 'undefined' && 'google' in window) {
resolve();
return;
}
const script = document.createElement('script');
script.src = GSI_SRC;
script.async = true;
script.defer = true;
script.onload = () => resolve();
script.onerror = () => reject(new Error('Failed to load Google Identity Services'));
document.head.appendChild(script);
});
return gsiPromise;
}
// Decode the JWT payload for DISPLAY ONLY — the signature is NOT verified here.
// The backend must verify the token before trusting any of these claims.
function decodeIdToken(jwt: string): GoogleIdTokenClaims | undefined {
try {
const payload = jwt.split('.')[1];
const b64 = payload.replace(/-/g, '+').replace(/_/g, '/');
const json = decodeURIComponent(
atob(b64)
.split('')
.map((c) => '%' + c.charCodeAt(0).toString(16).padStart(2, '0'))
.join(''),
);
return JSON.parse(json) as GoogleIdTokenClaims;
} catch {
return undefined;
}
}
export const googleProvider: AuthProvider = {
id: 'google',
label: 'Continue with Google',
mount(container, { onResult, onError }) {
if (!CLIENT_ID) {
onError(new Error('VITE_GOOGLE_CLIENT_ID is not set'));
return;
}
let cancelled = false;
loadGsi()
.then(() => {
if (cancelled) return;
google.accounts.id.initialize({
client_id: CLIENT_ID,
ux_mode: 'popup',
auto_select: false,
callback: (resp) => {
onResult({
provider: 'google',
credential: resp.credential,
profile: decodeIdToken(resp.credential),
});
},
});
google.accounts.id.renderButton(container, {
type: 'standard',
theme: 'outline',
size: 'large',
text: 'continue_with',
shape: 'rectangular',
logo_alignment: 'left',
});
})
.catch((e) => onError(e instanceof Error ? e : new Error(String(e))));
return () => {
cancelled = true;
};
},
};
Enter fullscreen mode Exit fullscreen mode
2. Credential exchange — POST /validate/oauth
The frontend posts the credential to our API:
{ "provider": "google", "credential": "<google-id-token>", "profile": { ... } }
Enter fullscreen mode Exit fullscreen mode
We need an endpoint that is called after the frontend OAuth2 flow. There we validate the credential
the provider generated, then issue our own:
//com/weImpact/auth/controller/AuthController.kt
@PostMapping("/validate/oauth")
fun validateOAuth(@RequestBody req: OAuthLoginRequest, response: HttpServletResponse): TokenResponse {
val email = try {
validator.validate(req.credential)
} catch (e: JwtException) {
throw unauthorized()
}
// validate if the email exists
val user = users.findByEmail(email) ?: throw unauthorized()
// issue ours credentials
return issueFor(user, response)
}
Enter fullscreen mode Exit fullscreen mode
validator.validate performs full OIDC verification:
//com/weImpact/auth/security/OAuthCredentialValidator.kt
class OAuthCredentialValidator(
trustedIssuers: List<String>,
private val googleClientId: String,
decoderForIssuer: (String) -> JwtDecoder = { JwtDecoders.fromIssuerLocation(it) },
) {
private val decoders: Map<String, JwtDecoder> =
trustedIssuers.associate { it.trim() to decoderForIssuer(it.trim()) }
private val log = LoggerFactory.getLogger(OAuthCredentialValidator::class.java)
fun validate(credential: String): String {
val issuer = runCatching { SignedJWT.parse(credential).jwtClaimsSet.issuer }
.getOrNull() ?: throw BadJwtException("missing or malformed iss")
val decoder = decoders[issuer] ?: throw BadJwtException("untrusted issuer: $issuer")
val jwt = decoder.decode(credential)
if (googleClientId !in jwt.audience.orEmpty()) throw BadJwtException("unexpected audience")
val email = jwt.getClaimAsString("email") ?: throw BadJwtException("missing email claim")
if (jwt.getClaim<Any>("email_verified") != true) throw BadJwtException("email not verified")
return email
}
}
Enter fullscreen mode Exit fullscreen mode
- Parse the
issclaim and reject it unless it is a trusted issuer. - Require the audience to contain our Google
CLIENT_ID. - Require an
emailclaim andemail_verified: true. - Return the verified email.
This flow can validate different issuers. JwtDecoders.fromIssuerLocation gets information from the issuer (OIDC discovery);
the result can be cached to improve performance. It fetches <issuer>/.well-known/openid-configuration
(for example, Google's) over the network,
reads the jwks_uri from it, and builds a decoder wired to that provider's public keys (JWKS).
For Google, that's how it learns Google's signing keys.
3. Creating our credentials
A verified Google identity is not enough to get in. The email must map to a known,
ACTIVE user in our database; otherwise the request is rejected with 401.
For an accepted user we build an AppPrincipal (user id, email, role, institution ids) and
issue two HS256-signed JWTs via TokenService:
-
Access token — short-lived (
~15m). Carriesemail,role,institutionIds, andtoken_type=access. Sent to the frontend in the JSON response body and attached as aBearertoken on subsequent API calls. -
Refresh token — long-lived (
~30d). Carries onlytoken_type=refreshand the user id as subject. Never returned in the response body — it is delivered as an HttpOnly, Secure, SameSite cookie so page JavaScript cannot read it (XSS-safe).
{ "accessToken": "<jwt>", "tokenType": "Bearer", "expiresIn": 900 }
Enter fullscreen mode Exit fullscreen mode
private fun issueFor(user: User, response: HttpServletResponse): TokenResponse {
if (user.status != UserStatus.ACTIVE) throw unauthorized()
val institutionIds = memberships.findByUserId(user.id).map { it.institutionId }.toSet()
val principal = AppPrincipal(user.id, user.email, user.role.name, institutionIds)
val issued = tokens.issue(principal)
// Refresh token leaves only as an HttpOnly cookie (unreadable by page JS → XSS-safe),
// scoped to /refresh so it isn't attached to every request.
response.addHeader(HttpHeaders.SET_COOKIE, refreshCookie(issued.refreshToken).toString())
return TokenResponse(issued.accessToken, "Bearer", issued.expiresInSeconds)
}
private fun refreshCookie(token: String): ResponseCookie =
ResponseCookie.from(REFRESH_COOKIE, token)
.httpOnly(true)
.secure(refreshCookieSecure)
.sameSite(refreshCookieSameSite)
.path("/")
.maxAge(refreshTtl)
.build()
Enter fullscreen mode Exit fullscreen mode
With that we have created the credentials we can use on our business endpoints.
The frontend sends the header Authorization: Bearer <access-token>, and the server can verify its signature,
expiry, user information and roles, all without the need for password management.
References:


