Skip to main content
CivicGate

← Help

Applications

OpenID Connect sign-in for your own site Β· general API guidance lives at /help/api

Application or API key?

This is the decision to get right first. The two credentials look interchangeable and are not: an API key is you, an Application is somebody else.

API keyApplication (OAuth)
Acts as The account that created it β€” you. Whichever user signed in and consented, one at a time.
Can write No. Keys are read-only by design; the gateway refuses every mutation on a key-authenticated request. Yes β€” anything that user could do themselves, within the scopes they approved.
Safe in a public page Yes. That is what it is for. The client id yes; the client secret never.
Rate budget The key's own. The signed-in user's β€” see below.
Reach for it when You are displaying civic data β€” a widget, a dashboard, a script. You want people to sign in to your site with their CivicGate account.

If nobody has to log in to your thing, you want an API key and this page is the wrong one.

Registering an application

Register at Settings β†’ Applications. You give it a name, an optional description, and at least one redirect URI. The server mints the client id from your name plus a random suffix β€” you do not choose it, because an id nobody can squat is worth more than a pretty one.

ChoiceWhat it means
Confidential (default) Your server keeps a secret. Use this whenever you have a backend.
Public (tick the box) Single-page app or mobile. No secret is issued β€” a browser or phone cannot keep one. PKCE takes its place.

The secret exists in exactly one response

The column holds a bcrypt hash plus the first 12 characters for display. The full secret (cgs_ + 32 random bytes, base64url) is returned once β€” at creation, or at rotation β€” and there is no endpoint anywhere that can read it back. Lose it and your only move is Rotate secret, which invalidates the old one immediately: rotation is revocation, so sign-in through that application breaks until you have deployed the new value.

Limits on registration

Applications per account10
Redirect URIs per application10 (512 characters each)
Registrations per hour10, per account
Name / description length100 / 500 characters

The same endpoints back a scripted workflow, authorised with your normal session bearer token:

GET    https://auth.civicgate.org/api/v1/oidc/clients                        list your own
POST   https://auth.civicgate.org/api/v1/oidc/clients                        register (returns the secret ONCE)
GET    https://auth.civicgate.org/api/v1/oidc/clients/{clientId}
PATCH  https://auth.civicgate.org/api/v1/oidc/clients/{clientId}             name, description, logo, URIs, scopes, status
DELETE https://auth.civicgate.org/api/v1/oidc/clients/{clientId}
POST   https://auth.civicgate.org/api/v1/oidc/clients/{clientId}/rotate-secret

Every one of those filters on your user id inside the query, and a client that is not yours answers 404 rather than 403 β€” a distinguishable "forbidden" would confirm which client ids exist.

Endpoints

CivicGate publishes an OIDC Discovery document, so most libraries need only the issuer (https://auth.civicgate.org) and will configure themselves. Hand-wiring six URLs is where integrations usually get one wrong.

PurposeEndpoint
DiscoveryGET https://auth.civicgate.org/.well-known/openid-configuration
JWKS (public keys)GET https://auth.civicgate.org/.well-known/jwks.json
AuthorizationGET https://auth.civicgate.org/api/v1/oauth/authorize
TokenPOST https://auth.civicgate.org/api/v1/oauth/token
UserInfoGET or POST https://auth.civicgate.org/api/v1/oauth/userinfo
RP-initiated logoutGET https://auth.civicgate.org/api/v1/oauth/logout
Revocation https://auth.civicgate.org/api/v1/oauth/revoke β€” advertised in discovery but not implemented; it answers 404. See known gaps.

Supported: response_type=code only, response_mode=query, RS256 id tokens, and code_challenge_method=S256 only β€” plain PKCE is refused outright rather than accepted as a flow that merely looks protected. Client authentication may be client_secret_basic, client_secret_post or none (public clients).

The authorization-code + PKCE flow

1 Β· Send the person to the authorization endpoint

A top-level navigation, not a fetch. Generate a random code_verifier, keep it server-side or in session storage, and send its SHA-256 digest, base64url-encoded, as the challenge.

https://auth.civicgate.org/api/v1/oauth/authorize
  ?response_type=code
  &client_id=my-community-site-9f2a1c4b7e08
  &redirect_uri=https://example.com/auth/callback
  &scope=openid%20profile%20email
  &state=<random; compare it on the way back>
  &nonce=<random; compare it to the id_token's nonce claim>
  &code_challenge=<BASE64URL(SHA256(code_verifier))>
  &code_challenge_method=S256

openid is required β€” without it the request fails with invalid_scope. PKCE is required for every self-registered application, public or confidential: registration forces require_pkce on and it is not editable.

A navigation cannot carry an Authorization header

Only fetch can. That is why this one endpoint accepts the identity server's session cookie as well as a bearer token β€” without it, a reader who was already signed in arrived as an anonymous stranger and got bounced to the login page, which handed back a token the next navigation still could not send. So: do not try to attach a token to the authorize URL, and do not fetch it with XHR to read the Location β€” let the browser go there.

2 Β· They sign in and approve

Not signed in, they are sent to https://www.civicgate.org/login with the full authorize URL as next, and return here afterwards. Then CivicGate shows a consent screen naming your application and listing, in plain language, what it is asking for. Approval is carried by a server-minted nonce valid for 10 minutes, so an authorize URL with consent=granted pasted on cannot approve anything β€” never bookmark or replay a consent URL; build a fresh authorize request each time.

3 Β· CivicGate redirects back with a code

302 β†’ https://example.com/auth/callback?code=…&state=…

Compare state to what you sent. The code lives 2 minutes and is single-use. If something went wrong after the redirect URI was validated, you get ?error= + error_description= instead β€” invalid_scope, unsupported_response_type, unauthorized_client, invalid_request. If it went wrong before that (unknown client, or a redirect URI that is not registered) the error renders on CivicGate and no redirect happens at all, because redirecting to an unvalidated address is the exact attack this endpoint exists to refuse.

4 Β· Exchange the code for tokens

curl -X POST https://auth.civicgate.org/api/v1/oauth/token \
  -u 'my-community-site-9f2a1c4b7e08:cgs_…' \
  -d grant_type=authorization_code \
  -d code=… \
  -d redirect_uri=https://example.com/auth/callback \
  -d code_verifier=…
{
  "access_token": "…",
  "id_token": "…",
  "token_type": "Bearer",
  "expires_in": 900,
  "scope": "openid profile email"
}

Read the scope you got back β€” the granted set can be narrower than the set you asked for, and code that assumes otherwise misbehaves the first time a scope is refused. A public client sends no secret; its code_verifier is what proves it is the same party that started the flow.

redirect_uri must match the one the code was issued against. Failures are deliberately indistinguishable β€” unknown, expired and already-redeemed codes all answer invalid_grant, because saying which would help only somebody who had stolen one. One exception worth knowing: access_denied means the code was fine and the person is not authorised for that application, which sends you to an administrator rather than to your PKCE implementation.

5 Β· Verify the id_token

RS256, verified against JWKS β€” pick the key by the token's kid header, which is what makes a key rotation a non-event. Check iss, aud (your client id), expiry, and that nonce equals what you sent. Claims are filtered by scope: with only openid you get a subject and nothing else β€” not the name, not the email.

6 Β· Use the access token

curl https://auth.civicgate.org/api/v1/oauth/userinfo -H 'Authorization: Bearer …'

The same token authenticates that user against the CivicGate GraphQL API, which is what makes an Application able to write on their behalf. Two things to know before you build on that:

  • Mutations go to https://www.civicgate.org/graphql. The public endpoint api.civicgate.org/graphql is read-only for every credential and refuses mutations regardless of who you are.
  • What you can call with it is the generated schema reference: /api/reference.
  • A client_credentials token is not a CivicGate API credential. The gateway recognises service tokens and rejects them, deliberately β€” there is no app-scoped authorisation model yet, so "not this credential" is said out loud rather than returned as an empty result set that looks like an empty account. Self-registered applications cannot hold that grant in any case.

Redirect URI rules

This is the control the whole flow rests on. The authorize endpoint delivers a user's authorization code to whatever address your client names, and the only thing stopping an attacker naming their own is that it must already appear β€” character for character β€” in your registered list. Everything below exists so that exact match cannot be written loosely enough to stop meaning anything.

RuleThe server's own message
No wildcards "Wildcards are not allowed in redirect URIs β€” register each exact address separately"
No fragment "Redirect URIs must not contain a fragment (the part after #) β€” the browser never sends it, so it cannot be matched"
https only, except loopback "Redirect URIs must use https β€” plain http is allowed only for local development (http://localhost or http://127.0.0.1, with any port)"
No custom schemes "Redirect URIs must use https β€” custom schemes are not accepted"
No userinfo "Redirect URIs must not contain a username or password"
No control characters "Redirect URIs must not contain tabs, line breaks or other control characters"
Absolute "Redirect URIs must be absolute, starting with https:// (or http:// for localhost)"
At least one "At least one redirect URI is required" β€” a client with none cannot complete a single login

The loopback exception is checked on the parsed host, not on a string prefix: http://localhost.attacker.net/cb begins with http://localhost and is a completely different, attacker-controlled host. Only localhost, 127.0.0.1 and ::1 qualify, on any port.

Stored values are trimmed of spaces and de-duplicated, and otherwise kept byte for byte. Nothing is lowercased and no trailing slash is added or removed, because the stored string is what the authorize endpoint compares against β€” "helpfully" normalising it would store something your client never sends, and every login would fail with a mismatch you did not cause. Register https://example.com/cb and https://example.com/cb/ separately if you use both.

The rules are enforced identically on edit as on registration. A rule that only applies at creation is not a rule.

Scopes

Space-separated in scope. Unknown values are ignored, not rejected.

ScopeGrantsSelf-service?
openidRequired. The subject (sub) and the id token itself.Yes
profilename, given_name, family_name, preferred_username, picture, cg_roles.Yes
emailemail and email_verified.Yes
offline_accessA refresh token β€” standing, long-lived access.Administrator
civic:locationCity, state and country. Never the street address or postal code, at any tier.Administrator
civic:interestsThe civic topics the member follows.Administrator
civic:positionsPositions they have declared on issues. Sensitive.Administrator
civic:activityRepresentatives contacted and what was urged. Sensitive.Administrator

A self-registered application may request openid, profile and email, and asking for anything else is refused rather than silently trimmed β€” a silent drop would leave you with a client that authorises fine and then returns a token missing the claim you built on, with nothing anywhere explaining why. The message names the alternative:

A self-registered application may request only these scopes: openid, profile, email.
"civic:positions" is granted by a CivicGate administrator β€” contact us if your
application needs it.

The civic:* scopes read data a member has not agreed to hand to a stranger, so they are a human decision rather than a form field. The two marked sensitive carry political-belief data about a private individual and re-prompt on every authorization, even for a client the person approved before β€” silence must never be read as consent for that.

Token lifetimes and refresh

CredentialLives
Authorization code2 minutesSingle use. It is a bearer credential in a URL, the most-logged place a secret can be.
ID token10 minutesProof of a login event, not a session. Do not treat it as one.
Access token15 minutesexpires_in: 900.
Refresh token7 daysOnly issued under the conditions below.
Consent approval10 minutesAn expired consent screen re-prompts rather than erroring.

You probably will not get a refresh token

A refresh token is returned only when both hold: the granted scopes include offline_access, and the client is registered for the refresh_token grant. Self-registered clients are registered for the grant β€” and cannot request offline_access, so in practice they never receive one. That is deliberate: standing long-lived access to somebody's account is not something a registration form should be able to award itself.

So the normal pattern for a self-service application is: hold your own session for the person, and when you need CivicGate again, run the authorization flow again. Because they already consented, and their identity-server session is still live, the round trip is silent β€” no second consent screen unless a sensitive scope is involved.

If an administrator has granted your application offline_access:

curl -X POST https://auth.civicgate.org/api/v1/oauth/token \
  -u 'CLIENT_ID:CLIENT_SECRET' \
  -d grant_type=refresh_token \
  -d refresh_token=…

A refresh token is bound to the client it was issued to and cannot be redeemed by another, so a token you obtained by any other route will be refused here.

Signing out

https://auth.civicgate.org/api/v1/oauth/logout?client_id=…&post_logout_redirect_uri=https://example.com/

The redirect target must be registered as a post-logout URI on your client β€” same reason as the authorize endpoint, otherwise this is an open redirect wearing a logout costume. Unregistered (or omitted), it answers a plain JSON acknowledgement instead of redirecting.

Rate limits β€” whose budget you are spending

An Application spends the budget of the user who authorised it, not its own. This is not obvious and it changes how you plan capacity.

  • Ten users means ten separate budgets. Your application has no aggregate cap of its own.
  • One user hammering through your app exhausts only their own budget. Their neighbours are unaffected.
  • Every user's limit is set by their account tier, not yours. A free-tier user is metered at the free rate even if you are not.
  • Reads and writes share one budget. There is no separate write allowance.

Practically: batch per-user work, cache what you can, and do not fan a single background job across many users' tokens expecting a pooled ceiling β€” you will hit each of them in turn.

The tier numbers, the RATE_LIMITED error shape and the retryable flag are all on the API guide, which is the one place they are stated: /help/api β†’ Rate limits.

Troubleshooting

A CORS error that is not a CORS error

The browser says "No 'Access-Control-Allow-Origin' header is present on the requested resource", and the auth service logs status=200 for that exact request. Both are telling the truth. Three Set-Cookie headers, one carrying a large access JWT, can exceed nginx's default 4KB proxy_buffer_size; nginx then discards the upstream 200 and returns its own 502, which carries none of the CORS headers the upstream had set. It is size-dependent β€” a user with fewer roles has a smaller token and slips under the limit β€” so it presents as intermittent and as affecting only some accounts.

Check your proxy's error log for upstream sent too big header before touching any CORS configuration. The fix is buffer size, not headers: proxy_buffer_size 16k; proxy_buffers 8 16k; proxy_busy_buffers_size 32k;

SymptomUsually
redirect_uri is not registered for this client, rendered on CivicGate An exact-match miss β€” most often a trailing slash, a different port, or http against an https registration. Compare the two strings byte for byte.
Sent to /login forever You fetched the authorize endpoint instead of navigating to it. See step 1.
invalid_grant at the token endpoint The code expired (2 minutes), was already redeemed, or redirect_uri does not match the one it was issued against. It is also what a failed PKCE check returns.
access_denied at the token endpoint Not your code and not your PKCE β€” that person is not authorised for the application. Only an administrator can change it.
Token has no email or name You asked for openid alone. Claims are filtered by scope.
Consent screen every single time Expected for civic:positions and civic:activity. Sensitive scopes always re-prompt.
Mutations rejected with a read-only message You are calling api.civicgate.org. Writes go to www.civicgate.org/graphql.

Known gaps

Stated rather than left to be discovered, because both change what you can promise your users.

  • Token revocation is advertised and not implemented. The discovery document names https://auth.civicgate.org/api/v1/oauth/revoke; that URL answers 404. Do not build a sign-out that depends on it. Tokens expire on the schedule above, and an application can be stopped outright by disabling or deleting it, or by rotating its secret.
  • There is no self-service consent withdrawal yet. The consent screen says a person can revoke access in their account settings; that surface does not exist at the moment. Until it does, deleting your application is what clears its consents β€” so if a user asks you to disconnect them, do it on your side and say so honestly.

Related