August 9, 2026
Deep Dive: OIDC & SAML Implementation + SQL Injection & XSS Hardening (Part 2)
This continues the Cybersecurity Fundamentals article with full working walkthroughs for the two topics that came up most in your original…

By Boovaneshbk
6 min read
This continues the Cybersecurity Fundamentals article with full working walkthroughs for the two topics that came up most in your original vulnerability report: federated login (OIDC/SAML) and injection-class attacks (SQLi/XSS).
Part A — OIDC Implementation Walkthrough (Authorization Code + PKCE)
This is the flow you want for a Go backend + Vue frontend today — OAuth2 Authorization Code with PKCE, not the old implicit flow (implicit flow returns tokens directly in the URL fragment, which is exactly the "token exposed in the browser" problem you already fixed once).
sequenceDiagram
participant V as Vue Frontend
participant API as Go Backend (SP)
participant IdP as Identity Provider (Keycloak/Auth0/Okta)
V->>API: GET /auth/login
API->>API: generate code_verifier + code_challenge (PKCE)
API->>V: redirect to IdP with code_challenge, state
V->>IdP: user authenticates
IdP->>V: redirect back with authorization code
V->>API: GET /auth/callback?code=...&state=...
API->>IdP: POST /token (code + code_verifier)
IdP->>API: id_token + access_token + refresh_token
API->>API: verify id_token signature & claims
API->>V: Set-Cookie refresh_token (HttpOnly, Secure)<br/>return short-lived access_token in bodysequenceDiagram
participant V as Vue Frontend
participant API as Go Backend (SP)
participant IdP as Identity Provider (Keycloak/Auth0/Okta)
V->>API: GET /auth/login
API->>API: generate code_verifier + code_challenge (PKCE)
API->>V: redirect to IdP with code_challenge, state
V->>IdP: user authenticates
IdP->>V: redirect back with authorization code
V->>API: GET /auth/callback?code=...&state=...
API->>IdP: POST /token (code + code_verifier)
IdP->>API: id_token + access_token + refresh_token
API->>API: verify id_token signature & claims
API->>V: Set-Cookie refresh_token (HttpOnly, Secure)<br/>return short-lived access_token in body1. Setting up the provider client
package auth
import (
"context"
"github.com/coreos/go-oidc/v3/oidc"
"golang.org/x/oauth2"
)
type OIDCConfig struct {
IssuerURL string
ClientID string
ClientSecret string
RedirectURL string
}
func NewOIDCClient(ctx context.Context, cfg OIDCConfig) (*oidc.Provider, *oauth2.Config, error) {
provider, err := oidc.NewProvider(ctx, cfg.IssuerURL)
if err != nil {
return nil, nil, err
}
oauth2Config := &oauth2.Config{
ClientID: cfg.ClientID,
ClientSecret: cfg.ClientSecret,
RedirectURL: cfg.RedirectURL,
Endpoint: provider.Endpoint(),
Scopes: []string{oidc.ScopeOpenID, "profile", "email"},
}
return provider, oauth2Config, nil
}package auth
import (
"context"
"github.com/coreos/go-oidc/v3/oidc"
"golang.org/x/oauth2"
)
type OIDCConfig struct {
IssuerURL string
ClientID string
ClientSecret string
RedirectURL string
}
func NewOIDCClient(ctx context.Context, cfg OIDCConfig) (*oidc.Provider, *oauth2.Config, error) {
provider, err := oidc.NewProvider(ctx, cfg.IssuerURL)
if err != nil {
return nil, nil, err
}
oauth2Config := &oauth2.Config{
ClientID: cfg.ClientID,
ClientSecret: cfg.ClientSecret,
RedirectURL: cfg.RedirectURL,
Endpoint: provider.Endpoint(),
Scopes: []string{oidc.ScopeOpenID, "profile", "email"},
}
return provider, oauth2Config, nil
}2. Login handler — generating PKCE + state
func LoginHandler(oauth2Config *oauth2.Config) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
state := randomString(32)
verifier := oauth2.GenerateVerifier()
// store state + verifier server-side (Redis, keyed by a short-lived cookie)
sessionID := randomString(16)
redisClient.Set(r.Context(), "oidc:"+sessionID, verifier+"|"+state, 5*time.Minute)
http.SetCookie(w, &http.Cookie{
Name: "oidc_session", Value: sessionID,
HttpOnly: true, Secure: true, SameSite: http.SameSiteLaxMode,
MaxAge: 300,
})
authURL := oauth2Config.AuthCodeURL(state, oauth2.S256ChallengeOption(verifier))
http.Redirect(w, r, authURL, http.StatusFound)
}
}func LoginHandler(oauth2Config *oauth2.Config) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
state := randomString(32)
verifier := oauth2.GenerateVerifier()
// store state + verifier server-side (Redis, keyed by a short-lived cookie)
sessionID := randomString(16)
redisClient.Set(r.Context(), "oidc:"+sessionID, verifier+"|"+state, 5*time.Minute)
http.SetCookie(w, &http.Cookie{
Name: "oidc_session", Value: sessionID,
HttpOnly: true, Secure: true, SameSite: http.SameSiteLaxMode,
MaxAge: 300,
})
authURL := oauth2Config.AuthCodeURL(state, oauth2.S256ChallengeOption(verifier))
http.Redirect(w, r, authURL, http.StatusFound)
}
}3. Callback handler — exchanging code, verifying token
func CallbackHandler(provider *oidc.Provider, oauth2Config *oauth2.Config, clientID string) http.HandlerFunc {
verifier := provider.Verifier(&oidc.Config{ClientID: clientID})
return func(w http.ResponseWriter, r *http.Request) {
cookie, err := r.Cookie("oidc_session")
if err != nil {
http.Error(w, "missing session", http.StatusBadRequest)
return
}
stored, _ := redisClient.Get(r.Context(), "oidc:"+cookie.Value).Result()
parts := strings.SplitN(stored, "|", 2)
pkceVerifier, expectedState := parts[0], parts[1]
if r.URL.Query().Get("state") != expectedState {
http.Error(w, "state mismatch — possible CSRF", http.StatusForbidden)
return
}
token, err := oauth2Config.Exchange(r.Context(), r.URL.Query().Get("code"),
oauth2.VerifierOption(pkceVerifier))
if err != nil {
http.Error(w, "token exchange failed", http.StatusUnauthorized)
return
}
rawIDToken, ok := token.Extra("id_token").(string)
if !ok {
http.Error(w, "no id_token in response", http.StatusUnauthorized)
return
}
idToken, err := verifier.Verify(r.Context(), rawIDToken)
if err != nil {
http.Error(w, "invalid id_token", http.StatusUnauthorized)
return
}
var claims struct {
Email string `json:"email"`
Sub string `json:"sub"`
}
idToken.Claims(&claims)
// issue YOUR OWN short-lived app access token + set refresh token as HttpOnly cookie
appAccessToken := issueAppJWT(claims.Sub, claims.Email, 15*time.Minute)
appRefreshToken := issueAppJWT(claims.Sub, claims.Email, 7*24*time.Hour)
http.SetCookie(w, &http.Cookie{
Name: "refresh_token", Value: appRefreshToken,
HttpOnly: true, Secure: true, SameSite: http.SameSiteStrictMode,
Path: "/auth/refresh", MaxAge: int((7 * 24 * time.Hour).Seconds()),
})
json.NewEncoder(w).Encode(map[string]string{"access_token": appAccessToken})
}
}func CallbackHandler(provider *oidc.Provider, oauth2Config *oauth2.Config, clientID string) http.HandlerFunc {
verifier := provider.Verifier(&oidc.Config{ClientID: clientID})
return func(w http.ResponseWriter, r *http.Request) {
cookie, err := r.Cookie("oidc_session")
if err != nil {
http.Error(w, "missing session", http.StatusBadRequest)
return
}
stored, _ := redisClient.Get(r.Context(), "oidc:"+cookie.Value).Result()
parts := strings.SplitN(stored, "|", 2)
pkceVerifier, expectedState := parts[0], parts[1]
if r.URL.Query().Get("state") != expectedState {
http.Error(w, "state mismatch — possible CSRF", http.StatusForbidden)
return
}
token, err := oauth2Config.Exchange(r.Context(), r.URL.Query().Get("code"),
oauth2.VerifierOption(pkceVerifier))
if err != nil {
http.Error(w, "token exchange failed", http.StatusUnauthorized)
return
}
rawIDToken, ok := token.Extra("id_token").(string)
if !ok {
http.Error(w, "no id_token in response", http.StatusUnauthorized)
return
}
idToken, err := verifier.Verify(r.Context(), rawIDToken)
if err != nil {
http.Error(w, "invalid id_token", http.StatusUnauthorized)
return
}
var claims struct {
Email string `json:"email"`
Sub string `json:"sub"`
}
idToken.Claims(&claims)
// issue YOUR OWN short-lived app access token + set refresh token as HttpOnly cookie
appAccessToken := issueAppJWT(claims.Sub, claims.Email, 15*time.Minute)
appRefreshToken := issueAppJWT(claims.Sub, claims.Email, 7*24*time.Hour)
http.SetCookie(w, &http.Cookie{
Name: "refresh_token", Value: appRefreshToken,
HttpOnly: true, Secure: true, SameSite: http.SameSiteStrictMode,
Path: "/auth/refresh", MaxAge: int((7 * 24 * time.Hour).Seconds()),
})
json.NewEncoder(w).Encode(map[string]string{"access_token": appAccessToken})
}
}Key points that map directly back to your original fix:
stateprevents CSRF on the login flow.- PKCE (
code_verifier/code_challenge) prevents authorization-code interception attacks — critical since Vue is a public client. - You never let the IdP's tokens reach the browser directly; you mint your own short-lived app token, and put only the refresh token in an
HttpOnlycookie — the access token lives in memory on the frontend (a Vue store, cleared on tab close), never inlocalStorage.
Part B — SAML Implementation Walkthrough (SP-initiated)
SAML is heavier (XML, not JSON) but still common in enterprise B2B contexts (a client's corporate IdP like ADFS/Azure AD). Using crewjam/saml:
import "github.com/crewjam/saml/samlsp"
func NewSAMLMiddleware() (*samlsp.Middleware, error) {
keyPair, _ := tls.LoadX509KeyPair("sp-cert.pem", "sp-key.pem")
idpMetadataURL, _ := url.Parse("https://idp.client-corp.com/metadata")
idpMetadata, err := samlsp.FetchMetadata(context.Background(), http.DefaultClient, *idpMetadataURL)
if err != nil {
return nil, err
}
rootURL, _ := url.Parse("https://hcmtalentpro.com")
samlMW, err := samlsp.New(samlsp.Options{
URL: *rootURL,
Key: keyPair.PrivateKey.(*rsa.PrivateKey),
Certificate: keyPair.Leaf,
IDPMetadata: idpMetadata,
SignRequest: true,
})
return samlMW, err
}
// Wiring it up:
func main() {
samlMW, _ := NewSAMLMiddleware()
app := http.NewServeMux()
app.Handle("/saml/", samlMW)
app.Handle("/dashboard", samlMW.RequireAccount(dashboardHandler()))
http.ListenAndServe(":8080", app)
}import "github.com/crewjam/saml/samlsp"
func NewSAMLMiddleware() (*samlsp.Middleware, error) {
keyPair, _ := tls.LoadX509KeyPair("sp-cert.pem", "sp-key.pem")
idpMetadataURL, _ := url.Parse("https://idp.client-corp.com/metadata")
idpMetadata, err := samlsp.FetchMetadata(context.Background(), http.DefaultClient, *idpMetadataURL)
if err != nil {
return nil, err
}
rootURL, _ := url.Parse("https://hcmtalentpro.com")
samlMW, err := samlsp.New(samlsp.Options{
URL: *rootURL,
Key: keyPair.PrivateKey.(*rsa.PrivateKey),
Certificate: keyPair.Leaf,
IDPMetadata: idpMetadata,
SignRequest: true,
})
return samlMW, err
}
// Wiring it up:
func main() {
samlMW, _ := NewSAMLMiddleware()
app := http.NewServeMux()
app.Handle("/saml/", samlMW)
app.Handle("/dashboard", samlMW.RequireAccount(dashboardHandler()))
http.ListenAndServe(":8080", app)
}How the SP-initiated flow works:
sequenceDiagram
participant U as User
participant SP as Your App (SP)
participant IdP as Client's IdP (ADFS/Azure AD)
U->>SP: GET /dashboard (no session)
SP->>U: redirect to /saml/login
SP->>IdP: AuthnRequest (signed)
U->>IdP: authenticates
IdP->>SP: POST SAMLResponse (signed XML assertion)
SP->>SP: verify signature, audience, NotBefore/NotOnOrAfter
SP->>U: session cookie issuedsequenceDiagram
participant U as User
participant SP as Your App (SP)
participant IdP as Client's IdP (ADFS/Azure AD)
U->>SP: GET /dashboard (no session)
SP->>U: redirect to /saml/login
SP->>IdP: AuthnRequest (signed)
U->>IdP: authenticates
IdP->>SP: POST SAMLResponse (signed XML assertion)
SP->>SP: verify signature, audience, NotBefore/NotOnOrAfter
SP->>U: session cookie issuedThe critical validation step — and the one most home-grown SAML integrations get wrong — is verifying the assertion's XML signature, audience restriction (the assertion was issued for you, not another SP), and its validity window (NotBefore/NotOnOrAfter), so a captured assertion can't be replayed later. Libraries like crewjam/saml handle this for you — this is one case where hand-rolling your own XML parsing is a real risk (XML signature wrapping attacks are a known SAML-specific vulnerability class).
Part C — SQL Injection: expanded
The full vector list, not just the classic ' OR 1=1
Vector Example Fix Classic string injection email = ' + input Parameterized query Numeric injection (no quotes needed) id = + input (e.g. 1 OR 1=1) Parameterized query — quotes don't matter, binding does Second-order injection Malicious data stored once, executed later when reused in another query Parameterize on every query, not just the entry point Blind/boolean-based Attacker infers data via true/false page behavior, no visible error Same fix — parameterization removes the injection point entirely ORDER BY / column-name injection User-controlled sort field concatenated into SQL Whitelist allowed column names, never bind identifiers as values
Go — the ORDER BY case specifically (parameters can't bind identifiers, only values)
var allowedSortColumns = map[string]bool{
"created_at": true, "name": true, "status": true,
}
func BuildSortedQuery(sortBy string) (string, error) {
if !allowedSortColumns[sortBy] {
return "", fmt.Errorf("invalid sort column")
}
// safe now — sortBy is whitelisted, not user-controlled at this point
return fmt.Sprintf("SELECT * FROM employees ORDER BY %s", sortBy), nil
}var allowedSortColumns = map[string]bool{
"created_at": true, "name": true, "status": true,
}
func BuildSortedQuery(sortBy string) (string, error) {
if !allowedSortColumns[sortBy] {
return "", fmt.Errorf("invalid sort column")
}
// safe now — sortBy is whitelisted, not user-controlled at this point
return fmt.Sprintf("SELECT * FROM employees ORDER BY %s", sortBy), nil
}MongoDB equivalent (relevant to your MongoDB work) — NoSQL injection via operators
MongoDB has its own injection class: if you unmarshal raw JSON request bodies directly into a query filter, an attacker can inject operators like $gt, $ne, $where.
// VULNERABLE — if `filter` comes straight from request JSON:
// {"password": {"$ne": null}} bypasses the password check entirely
var filter bson.M
json.NewDecoder(r.Body).Decode(&filter)
collection.FindOne(ctx, filter)
// FIXED — decode into a typed struct, build the query yourself:
var req struct {
Email string `json:"email"`
Password string `json:"password"`
}
json.NewDecoder(r.Body).Decode(&req)
collection.FindOne(ctx, bson.M{"email": req.Email}) // password checked via bcrypt after fetch, never in the query filter// VULNERABLE — if `filter` comes straight from request JSON:
// {"password": {"$ne": null}} bypasses the password check entirely
var filter bson.M
json.NewDecoder(r.Body).Decode(&filter)
collection.FindOne(ctx, filter)
// FIXED — decode into a typed struct, build the query yourself:
var req struct {
Email string `json:"email"`
Password string `json:"password"`
}
json.NewDecoder(r.Body).Decode(&req)
collection.FindOne(ctx, bson.M{"email": req.Email}) // password checked via bcrypt after fetch, never in the query filterDefense in depth beyond parameterization
- Least-privilege DB user: your app's DB credentials shouldn't have
DROP/ALTERrights — limits blast radius even if injection somehow occurs. - WAF rule (AWS WAF managed rule group
AWSManagedRulesSQLiRuleSet) as a network-layer backstop — not a substitute for parameterized queries, but catches drive-by scanners.
resource "aws_wafv2_web_acl" "app" {
name = "app-waf"
scope = "REGIONAL"
default_action { allow {} }
rule {
name = "AWS-SQLi-Protection"
priority = 1
override_action { none {} }
statement {
managed_rule_group_statement {
name = "AWSManagedRulesSQLiRuleSet"
vendor_name = "AWS"
}
}
visibility_config {
cloudwatch_metrics_enabled = true
metric_name = "sqli-rule"
sampled_requests_enabled = true
}
}
visibility_config {
cloudwatch_metrics_enabled = true
metric_name = "app-waf"
sampled_requests_enabled = true
}
}resource "aws_wafv2_web_acl" "app" {
name = "app-waf"
scope = "REGIONAL"
default_action { allow {} }
rule {
name = "AWS-SQLi-Protection"
priority = 1
override_action { none {} }
statement {
managed_rule_group_statement {
name = "AWSManagedRulesSQLiRuleSet"
vendor_name = "AWS"
}
}
visibility_config {
cloudwatch_metrics_enabled = true
metric_name = "sqli-rule"
sampled_requests_enabled = true
}
}
visibility_config {
cloudwatch_metrics_enabled = true
metric_name = "app-waf"
sampled_requests_enabled = true
}
}Part D — XSS: expanded
The three types
graph TD
XSS[XSS] --> Reflected[Reflected<br/>payload in URL/query, echoed back immediately, not stored]
XSS --> Stored[Stored<br/>payload saved server-side, served to every future viewer]
XSS --> DOM[DOM-based<br/>client-side JS writes untrusted data into the DOM, no server round-trip]graph TD
XSS[XSS] --> Reflected[Reflected<br/>payload in URL/query, echoed back immediately, not stored]
XSS --> Stored[Stored<br/>payload saved server-side, served to every future viewer]
XSS --> DOM[DOM-based<br/>client-side JS writes untrusted data into the DOM, no server round-trip]Stored XSS is the most dangerous in a system like yours with multiple company types (Staffing/NEEM/NAPS) — a malicious payload in one employee record (e.g. a "name" or "notes" field) gets rendered for every HR user who views that record, and could target admin sessions specifically.
Output encoding by context — this is the part people get wrong
Escaping isn't one-size-fits-all; the correct encoding depends on where the data lands:
Context Example Encoding needed HTML body <div>{{ name }}</div> HTML entity encoding HTML attribute <input value="{{ name }}"> Attribute encoding (quotes especially) JavaScript string var x = "{{ name }}"; JS string escaping — HTML encoding alone is NOT enough here URL parameter <a href="/search?q={{ q }}"> URL encoding
Vue's default {{ }} interpolation and :attribute bindings handle HTML-body and attribute contexts automatically. The dangerous spots are the ones where someone bypasses that:
<!-- VULNERABLE: v-html renders raw, unescaped HTML -->
<div v-html="employee.notes"></div>
<!-- FIXED: sanitize first if HTML is genuinely needed (e.g. rich text notes) -->
<div v-html="sanitizedNotes"></div>
import DOMPurify from 'dompurify'
const sanitizedNotes = computed(() => DOMPurify.sanitize(employee.value.notes))<!-- VULNERABLE: v-html renders raw, unescaped HTML -->
<div v-html="employee.notes"></div>
<!-- FIXED: sanitize first if HTML is genuinely needed (e.g. rich text notes) -->
<div v-html="sanitizedNotes"></div>
import DOMPurify from 'dompurify'
const sanitizedNotes = computed(() => DOMPurify.sanitize(employee.value.notes))If you don't need any HTML formatting at all (most form fields), just use plain interpolation — {{ employee.notes }} — and skip v-html entirely; that alone closes most stored-XSS paths.
Content-Security-Policy — defense in depth even if one escaping spot is missed
func SecurityHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Security-Policy",
"default-src 'self'; script-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'")
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("X-Frame-Options", "DENY")
next.ServeHTTP(w, r)
})
}func SecurityHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Security-Policy",
"default-src 'self'; script-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'")
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("X-Frame-Options", "DENY")
next.ServeHTTP(w, r)
})
}script-src 'self' means even if an attacker gets a <script> tag injected somehow, the browser refuses to execute it unless it's loaded from your own origin — a strong last line of defense.
Input validation ties back to your file-upload fix
The same principle you already applied when you fixed the extension-only file validation bug (checking magic bytes/MIME type, not trusting the client-reported extension) applies to every other user input: never trust client-side validation as your security boundary — it's a UX nicety; the server-side check is the actual control. That's the same lesson underlying both the SQLi and XSS fixes above: validate/encode server-side, every time, regardless of what the frontend already checked.
Summary — what changed vs. the original report
Original finding Root cause This walkthrough's fix Token in localStorage Accessible to any XSS payload Access token in memory, refresh token in HttpOnly cookie, PKCE-based OIDC flow Session/cookie issues Missing flags, no rotation Secure/HttpOnly/SameSite=Strict, server-side refresh-token rotation (Implied) injection surface Any dynamic query built from user input Parameterized SQL, whitelisted identifiers, typed Mongo filters (Implied) stored-content risk Unsanitized rendering of user-submitted fields Context-aware encoding, DOMPurify for genuine rich text, strict CSP