A Go library for integrating Singpass Login,
Myinfo and
Myinfo Business (Corppass)
over the FAPI 2.0 Security Profile — PAR, DPoP, private_key_jwt, PKCE,
encrypted id_tokens and /userinfo — without rediscovering the
Singpass/Corppass-specific details. The protocol is driven by
FAPIgo; this library supplies the
Singpass pieces.
Unofficial. A community project, not affiliated with or endorsed by GovTech, Singpass or Corppass.
- One constructor per product —
NewLogin,NewMyinfo,NewMyinfoBusiness— with working staging defaults. - Typed Myinfo data —
PersonProfile()andEntityProfile()name the common items, with dates, numbers, addresses and phone numbers parsed; provenance kept on every item; Corppass's double-encoded blocks andauth_infohandled for you. - HSM/KMS-ready — the signing key is any
crypto.Signer; decryption can use an external ECDH agreer, so private keys needn't enter the process. - Optional
net/httphelper — login, callback, logout and JWKS routes; you render the pages. - Test without onboarding —
singpasstestruns a fake Singpass/Corppass server in-process for integration tests, thesingpass-fake-serverDocker image runs it standalone for apps in any language, and the demo runs against it with one environment variable. - Production pieces included —
Environment: singpass.Production, durable session stores (sqlstore), key rotation without downtime, a published-JWKS check, retries for temporary Singpass errors, browser security headers (web.SecureHeaders) and a key generator (singpass-keygen).
Try it now, no Singpass account or onboarding needed:
git clone https://github.com/osanderson/singpass-client-go
cd singpass-client-go/examples/demo
DEMO_MOCK=1 go run ./cmd/server # open http://localhost:8088Not using Go? The fake Singpass and Corppass servers are a Docker image, for local development and CI with apps in any language:
docker run --rm -p 5156:5156 -p 5157:5157 ghcr.io/osanderson/singpass-fake-serverSingpass is then at http://localhost:5156/fapi and Corppass at
http://localhost:5157, each with discovery, PAR, DPoP, private_key_jwt,
encrypted id_tokens and /userinfo as the real FAPI 2.0 endpoints do them,
built-in test clients and users, and a dashboard at /_fake/. See
Testing your integration and
docs/docker.md for Docker Compose, GitHub Actions and HTTPS.
go get github.com/osanderson/singpass-client-goRequires Go 1.26.9+ (FAPIgo's minimum, which fixes GO-2026-6617). The package name is singpass.
A complete Singpass Login app — also in examples/minimal:
sigKey, _ := keyfile.LoadECPrivateKey("keys/sig.pem") // keys you registered with Singpass
encKey, _ := keyfile.LoadECPrivateKey("keys/enc.pem")
client, err := singpass.NewLogin(ctx, singpass.LoginOptions{
ClientID: "your-client-id",
RedirectURI: "https://app.example.com/login/callback",
Scopes: []string{"openid", "name"},
SigningKey: sigKey,
SigningKID: "login-sig-1",
EncryptionKey: encKey,
EncryptionKID: "login-enc-1",
}, singpass.Dependencies{}) // zero value = staging defaults
if err != nil {
log.Fatal(err)
}
jwks, _ := client.PublicJWKS(ctx)
h := web.New(web.Config{
Apps: []*web.App{{Name: "login", Title: "Singpass", Auth: client, JWKS: jwks}},
SessionIdentity: web.MinimalIdentity, // keep personal data out of the login session
LoginRateLimit: &web.LoginRateLimit{}, // 10 logins at once per IP, then 10 a minute
// Behind HTTPS, also set Cookies: web.DefaultCookieConfig(true) (Secure, __Host- names).
})
mux := h.Mux() // /login/login, /login/callback, /login/logout, /login/jwks.json
mux.Handle("/", web.NoStore(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if id, ok := h.CurrentIdentity(r); ok { // never cache pages showing the identity
fmt.Fprintf(w, "Signed in as %s", id.Subject)
return
}
fmt.Fprint(w, `<a href="/login/login">Log in with Singpass</a>`)
})))
log.Fatal(http.ListenAndServe(":8080", web.SecureHeaders(mux))) // CSP, anti-framing, HSTS, …Not using the helper? client.BeginLogin(ctx) returns the redirect URL and the
login's state — keep it in a cookie — and client.Complete(ctx, r.URL.RawQuery, state)
validates the callback against that browser's state and returns the
*singpass.Identity — see the
Client.Complete example.
A cancelled or denied login comes back as a *singpass.DeniedError, and a stale
one (expired, reloaded or replayed callback) matches
errors.Is(err, singpass.ErrLoginExpired) — show a "please try again" page for
it rather than an error. singpass.ErrorCode(err) gives the error code Singpass
sent, and singpass.IsTemporary(err) says whether trying again may help
(Dependencies.BeginLoginRetries retries BeginLogin for you).
For Singpass Login, LoginOptions.AuthContextMessage — or
client.BeginLoginWith(ctx, singpass.LoginContext{Message: …}) for one login —
tells the user what they're authenticating for, and AppClaimedHTTPS /
AppLaunchURL cover logins started from a mobile app.
New to Singpass? The onboarding guide walks through the developer portal and maps each setting to these options.
Need keys to register? The singpass-keygen command creates both keys (or
reuses existing ones — it never overwrites) and prints the public JWKS:
go install github.com/osanderson/singpass-client-go/cmd/singpass-keygen@latest
singpass-keygen -dir keys/login -sig-kid login-sig-1 -enc-kid login-enc-1 > login.jwks.jsonTo publish several keys, as in a key rotation, list each one with
-sig PATH=KID / -enc PATH=KID instead of -dir.
In code, keyfile.LoadOrGenerate and singpass.OfflineClientJWKS do the same
(example).
Once your JWKS is published at the URL you registered, check it serves these
keys. A JWKS URL with stale or other keys otherwise shows up at login only as
invalid_client:
singpass-keygen -dir keys/login -sig-kid login-sig-1 -enc-kid login-enc-1 -check https://app.example.com/login/jwks.jsonFrom a running app, client.CheckPublishedJWKS(ctx, url) does the same, e.g.
as a readiness check. The constructors also check your options up front:
a missing openid scope, a malformed, non-https or IP-address redirect URI and
an empty key ID fail at startup, with an error naming the option, rather than at
Singpass.
Singpass and Corppass publish staging test personas with Myinfo data:
Myinfo test personas
and Myinfo Business test personas.
Log in with the persona's UINFIN and the password given on those pages. The
Myinfo personas also sign in to staging Singpass Login apps, but Singpass
doesn't support that use. Persona data changes without notice, so don't
hard-code it. No staging client yet? Try the demo with DEMO_MOCK=1, or run
your own app against singpass-fake-server.
NewMyinfo and NewMyinfoBusiness take options shaped like NewLogin's.
Myinfo scopes are per data item; myinfo.Scopes expands items to the scopes
Singpass's data catalogue lists for them (vehicles alone is 37):
Scopes: myinfo.Scopes("openid", myinfo.ItemName, myinfo.ItemVehicles). For
Myinfo Business, use the block-prefixed items:
myinfo.Scopes("openid", myinfo.EntityBasicProfile, myinfo.EntityAppointments, myinfo.UserName).
Complete then also calls /userinfo and fills Identity.Myinfo. The typed
profiles name the common items, so there are no keys to look up:
p := id.Myinfo.PersonProfile()
p.Name.String() // "TAN XIAO HUI"
p.Nationality.Code() // "SG" (.String() gives "SINGAPORE CITIZEN")
dob, ok := p.DOB.Date() // time.Time
p.RegAdd.Lines() // ["102 BEDOK NORTH AVENUE 4", "#09-128", "SINGAPORE 460102"]
p.MobileNo.E164() // "+6597399245"
p.CPFBalances.OA.Float() // 1581.48, true
p.NOABasic.Amount.Float() // latest assessable income; .NOA for the breakdown
p.Vehicles[0].COEExpiryDate.Date() // also HDBOwnership, DrivingLicence, NOAHistory, CPFContributions, …
p.PartialUINFIN.String() // "****381D": masked NRIC, for display without the full number
p.Email.Available() // false when Myinfo has no value
p.Name.SourceCode() // myinfo.SourceGovernmentVerified
e := id.Myinfo.EntityProfile() // Myinfo Business
e.Name.String(); e.RegistrationNumber.String(); e.UENStatus.Code(); e.Address.String()
e.Appointments; e.Shareholders // person or entity party, flattened
e.Financials[0].Company.Revenue.Float() // also Capitals, Licences, Grants, History, …
id.Myinfo.CorppassProfile().Email // the acting user's Corppass account
id.Myinfo.Auth.Authorisations() // Corppass auth_info, flattenedEvery item is a myinfo.Field, keeping its value, code, provenance and
classification. For items the profiles don't model, read the blocks by key:
id.Myinfo.Person.Object("drivinglicence") // .Entity, .Corppass, .Auth, .TPAuth for Business
id.Myinfo.Person.List("vehicles") // repeated records
id.Myinfo.Person.Leaves() // every item with its key path, e.g. for a table or a DB row
p.RegAdd.Data.EffectiveSource() // provenance, incl. source declared on the container
myinfo.Label("hdbownership") // "HDB ownership" — display labels for keys
id.Myinfo.Raw() // the full decoded responseAbsent blocks and fields return zero values rather than panicking. The id_token
claims have typed accessors too: id.Issuer(), id.AuthMethods(),
id.AssuranceLevel(), id.SubjectType(), and id.SubjectAttributes() with
named fields (IdentityNumber, AccountType, Name, … for a person;
EntityName, EntityRegNumber, … for a Corppass entity). For Corppass,
id.ActingParty() is the person acting for the entity, with the same typed
Attributes.
| Import | Purpose |
|---|---|
singpass-client-go |
Client, the product constructors, Identity, DeniedError |
…/myinfo |
Myinfo data model; standard library only |
…/web |
Optional net/http handlers and login sessions |
…/keyfile |
Generate and load EC P-256 PEM keys |
…/singpasstest |
Fake Singpass / Corppass server for tests and demos |
…/sqlstore |
Durable session stores on Postgres, MySQL or SQLite (database/sql, no dependencies) |
…/cmd/singpass-keygen |
Command: create a client's keys and print the JWKS to register |
…/cmd/singpass-fake-server |
Command: run the fake Singpass and Corppass servers standalone, for apps in any language |
singpasstest runs a fake Singpass or Corppass authorization server in-process,
built on FAPIgo's real server engine: PAR, DPoP, private_key_jwt, PKCE,
encrypted id_tokens and signed, encrypted /userinfo, plus the
Singpass/Corppass quirks. Register your client with it, point the client at
srv.Issuer() with Dependencies{AllowLoopbackHTTP: true}, and drive a full
login in a test. Its built-in test users carry Myinfo data across the typed
datasets (CPF, income tax, HDB, vehicles, driving licence, children, a
foreigner's pass), and its sign-in page — or singpasstest.UserPersona /
EntityPersona in code — logs in as any NRIC, FIN or UEN you choose. See the
example.
To use the fake servers from an app in any language, or by hand in a browser, run them standalone:
docker run --rm -p 5156:5156 -p 5157:5157 ghcr.io/osanderson/singpass-fake-server
# or, with Go:
go run github.com/osanderson/singpass-client-go/cmd/singpass-fake-server@latestThe image runs Singpass at http://localhost:5156/fapi and Corppass at
http://localhost:5157, each with a dashboard at /_fake/ showing its
clients, test users and recent requests. docs/docker.md covers
configuring it, GitHub Actions, Docker Compose, and HTTPS with a test CA for
apps that reach it by a service name.
With no configuration it registers built-in test clients — login-test,
myinfo-test and myinfo-business-test, allowed every scope and any
http://localhost redirect URI — whose keys it serves at
/_fake/test-client/jwks.json. Those keys are published, so they're for
this fake only. To use your app's own keys, register it with
-client-id, -client-product, -client-redirect-uris, -client-scopes
and -client-jwks-url, or register several clients from a JSON file with
-config. Every flag can also be set from an environment variable
(-singpass-url is FAKE_SINGPASS_URL). See the
command's documentation.
Choosing the user in a headless test. Send MockPass's X-Custom-*
headers on the authorization request (the GET of the URL BeginLogin
returns) and the login is approved straight away as that user, even with the
sign-in page on:
| Header | Value |
|---|---|
X-Custom-NRIC |
the NRIC or FIN: a test user's, or any other for a new user |
X-Custom-UEN |
Corppass only, and required there: the entity |
X-Custom-UUID |
optional: replaces the user's Singpass UUID (sub, or act.sub on Corppass) |
X-Custom-Name |
optional: a new user's name |
X-Custom-Error |
access_denied: the user cancels |
Each login chooses its own user, so parallel tests don't interfere. In Go,
srv.AuthorizeAs(ctx, url, singpasstest.LoginAs{NRIC: "S8012345F"}) does the
same.
Your own test users. Add them from a JSON file — to the standalone server
with -personas users.json, to the demo's mock mode with
DEMO_MOCK_PERSONAS=users.json, or to a test with
singpasstest.LoadPersonas.
Each user is an NRIC (or, for Corppass, a UEN and the acting person's NRIC)
with optional /userinfo data in Myinfo's shape, so a real staging response —
like the demo's "Raw /userinfo response" — can be pasted in as-is:
[{"nric": "S1234567D", "userinfo": {"person_info": {"name": {"value": "TAN AH KOW"}}}}]The defaults target staging. For production, set
Environment: singpass.Production in the product options: it selects the
production issuer and production assurance, under which the in-memory session
store is refused, so supply a durable Dependencies.Sessions — sqlstore
provides one (and the web helper's login sessions) on Postgres, MySQL or
SQLite — declare durable keys with
Dependencies.KeyCustody: singpass.KeyCustody{Durable: true}, and give every
instance the same DPoPKey, since Singpass binds each login to the DPoP key it
started with. Work through the go-live checklist, which also
covers rotating keys without downtime.
- API reference and examples on pkg.go.dev
- Onboarding — from portal setup to a working staging login, step by step
- Troubleshooting — error codes and symptoms, with their causes and fixes
- Configuration — options, dependencies, keys, staging vs production
- Going to production — the go-live checklist
- Upgrading — what to change for each breaking release
- How it works — what Singpass needs and where each piece is handled
- Singpass quirks and Corppass quirks — non-obvious server behaviour
- Examples —
minimal(one product, ~75 lines) anddemo(all three, live on staging)
The project is pre-1.0 and follows semantic versioning:
- Patch releases (
0.9.x) never break the API. - Minor releases (
0.x.0) may. Each breaking change is marked in the CHANGELOG and explained, with before/after code, in UPGRADING.md. - 1.0 will follow once the API has held through real integrations of all three products and a release cycle without breaking changes; from then on, breaking changes wait for a major version.
Much of the churn so far has come from tracking FAPIgo, which is also pre-1.0; this library absorbs its changes where it can, so most FAPIgo upgrades need nothing from you.
Contributions are welcome — see CONTRIBUTING.md. Please report vulnerabilities privately as described in SECURITY.md.